feat(transcriber): deep module выполнения транскрипции (#58)

Закрывает #56. Спецификация: docs/specs/2026-09-21-transcription-execution.md.
This commit was merged in pull request #58.
This commit is contained in:
ddmitry committed 2026-09-22 09:46:22 +03:00
commit 328580abfb
21 files changed
+1286 -1675

No files matched your search

+12 -9
View File
@@ -25,11 +25,12 @@ Package manager is **uv** (not pip). Build backend is hatchling.
``` ```
CLI (cli.py) CLI (cli.py)
→ config.py cascade: CLI arg → .transcriber.toml → device-aware default → hardcoded → config.py cascade: CLI arg → .transcriber.toml (model/compute_type stay None for the module to resolve)
→ utils.py detect_device(), validate files, expand globs (Windows workaround) → utils.py detect_device(), validate files, expand globs (Windows workaround)
→ context_menu.py Windows SendTo: Transcribe.cmd install/uninstall (--install-menu / --uninstall-menu) → context_menu.py Windows SendTo: Transcribe.cmd install/uninstall (--install-menu / --uninstall-menu)
→ transcriber.py load_model() → get_backend(device) → ensure_model_available → create_model → transcriber.py Transcriber(ExecutionRequest): resolves auto + device defaults,
_transcribe_file() with mid-stream CUDA→CPU fallback get_backend(device) → ensure_model_available → create_model once per run,
transcribe(file) per file; fallback and ExecutionInfo stay inside
→ formatter.py segments → markdown with timestamps, paragraph grouping (>2s pause or >60s) → formatter.py segments → markdown with timestamps, paragraph grouping (>2s pause or >60s)
``` ```
@@ -47,19 +48,21 @@ Three backends implement the `Backend` Protocol (structural typing, no inheritan
### Key design decisions ### Key design decisions
- **Two-level fallback**: GPU→CPU at model load time AND mid-stream during transcription (GPU visible via nvidia-smi but insufficient VRAM). - **Execution module** (`Transcriber`): one owner per run for model handle, adapter, actual device and allowed GPU→CPU fallback (load time and mid-stream). Callers get `TranscribeResult` + `ExecutionInfo` (requested vs actual device, engine, model, compute type, threads, `runtime_info()` from the adapter for `--verbose`). Fallback is reachable only with `strict_device=False` (Python API): CLI explicit device is strict, `auto` is ONNX CPU.
- **CUDA bootstrap** (`_cuda_bootstrap.py`): preloads `libcublas.so.12` via `ctypes.CDLL(RTLD_GLOBAL)` before importing ctranslate2, because pip's `nvidia-cublas-cu12` installs to a non-standard path and `LD_LIBRARY_PATH` can't be changed at runtime (glibc caches it). - **CUDA bootstrap** (`_cuda_bootstrap.py`): preloads `libcublas.so.12` via `ctypes.CDLL(RTLD_GLOBAL)` before importing ctranslate2, because pip's `nvidia-cublas-cu12` installs to a non-standard path and `LD_LIBRARY_PATH` can't be changed at runtime (glibc caches it).
- **Batch mode**: 3-phase pipeline (prescan → load model once → transcribe all). `TranscribeFileResult` carries updated model/backend/device state between files. - **Batch mode**: 3-phase pipeline (prescan → create `Transcriber` → transcribe all). No state is carried between files by the CLI.
- **Device-aware defaults**: `compute_type` and `model` vary by device (float16 for CUDA, int8 for OpenVINO, float32 for CPU). Defined in `config.py` `DEVICE_DEFAULTS`. - **Device-aware defaults**: `compute_type` and `model` vary by device (float16 for CUDA, int8 for OpenVINO, float32 for CPU). Defined in `config.py` `DEVICE_DEFAULTS`, applied by `Transcriber` when the request leaves them `None`.
- **ONNX thread budget**: `--threads` sets `intra_op_num_threads` for both ASR and VAD sessions via `SessionOptions`; 0 keeps onnxruntime defaults.
- **OpenVINO uses pre-quantized models** — `compute_type` selects which HF repo to download, not a runtime parameter. - **OpenVINO uses pre-quantized models** — `compute_type` selects which HF repo to download, not a runtime parameter.
## Testing ## Testing
All tests mock backends — no real model downloads or transcription. Key test patterns: All tests mock backends — no real model downloads or transcription. Key test patterns:
- CLI tests: `typer.testing.CliRunner` + mocks for `load_config`, `detect_device`, `load_model`, `_transcribe_file`, `write_transcript` - CLI tests: `typer.testing.CliRunner` with the real `Transcriber` and a fake adapter patched at `local_transcriber.transcriber.get_backend`; `load_config`, `validate_input_file`, `write_transcript` mocked
- `_single_patches()` — helper assembling standard happy-path mock set - `_cli_run()` — context manager assembling that standard set, yields `(backend, write_transcript)`
- `_make_result()` / `_make_tfr()` — factories for test data - `_make_result()` / `_make_backend()` — factories for test data
- Module tests (`test_transcriber.py`): `Transcriber` through its interface with `_make_run_backend()`; adapter tests patch the libraries (`onnx_asr.load_model`, `faster_whisper.WhisperModel`)
## Common tasks ## Common tasks
+8 -8
View File
@@ -43,25 +43,25 @@ tests/
CLI (cli.py) CLI (cli.py)
→ config.py: загрузка .transcriber.toml, каскад дефолтов → config.py: загрузка .transcriber.toml, каскад дефолтов
→ utils.py: валидация файлов, определение устройства → utils.py: валидация файлов, определение устройства
→ transcriber.py: загрузка модели (с CUDA fallback), транскрипция → transcriber.py: Transcriber — разрешение устройства, загрузка модели один раз на запуск, транскрипция, fallback
→ formatter.py: сегменты → markdown с таймкодами → formatter.py: сегменты → markdown с таймкодами
→ запись результата → запись результата
``` ```
## Ключевые архитектурные решения ## Ключевые архитектурные решения
- **CUDA fallback** — двухуровневый: при загрузке модели и при транскрипции (mid-stream). GPU может быть видна через nvidia-smi, но не иметь достаточно VRAM. - **Module выполнения** (`Transcriber` в `transcriber.py`) — один владелец модели, adapter'а и фактического исполнения на запуск. Fallback GPU→CPU (при загрузке и mid-stream) живёт внутри и доступен только при `strict_device=False` из Python API: явный device в CLI strict, `auto` — ONNX CPU.
- **Device-aware дефолты** — `compute_type` зависит от устройства (`float16`/`float32`). `float16` не работает на CPU, `float32` расточителен на GPU. - **Device-aware дефолты** — `model` и `compute_type` без явного значения разрешаются по устройству при загрузке (`float16`/`float32`). `float16` не работает на CPU, `float32` расточителен на GPU.
- **cuBLAS bootstrap** (`_cuda_bootstrap.py`) — preload через ctypes до импорта ctranslate2. pip-пакет `nvidia-cublas-cu12` ставит `.so` в нестандартное место, а `LD_LIBRARY_PATH` нельзя изменить в рантайме. - **cuBLAS bootstrap** (`_cuda_bootstrap.py`) — preload через ctypes до импорта ctranslate2. pip-пакет `nvidia-cublas-cu12` ставит `.so` в нестандартное место, а `LD_LIBRARY_PATH` нельзя изменить в рантайме.
- **Батч-режим** — 3 фазы (prescan → load model → transcribe). Модель загружается один раз (~2-5 сек), невалидные файлы отсеиваются до загрузки. - **Батч-режим** — 3 фазы (prescan → создание `Transcriber` → transcribe). Модель загружается один раз (~2-5 сек), невалидные файлы отсеиваются до загрузки; CLI не переносит состояние между файлами.
- **Ручной glob в utils** — typer на Windows не раскрывает `*.mp4`, поэтому глобы обрабатываются явно. - **Ручной glob в utils** — typer на Windows не раскрывает `*.mp4`, поэтому глобы обрабатываются явно.
## Тестирование ## Тестирование
- Все CLI-тесты через `typer.testing.CliRunner` + моки (faster-whisper не вызывается) - Все CLI-тесты через `typer.testing.CliRunner` с настоящим `Transcriber` и fake adapter, подменённым в `local_transcriber.transcriber.get_backend` (библиотеки не вызываются)
- Моки: `load_config`, `validate_input_file`, `detect_device`, `ensure_model_available`, `load_model`, `_transcribe_file`, `write_transcript` - Моки: `load_config`, `validate_input_file`, `write_transcript`
- Паттерн: `_single_patches()` — хелпер для стандартного happy-path набора моков - Паттерн: `_cli_run()` — контекстный менеджер стандартного набора, отдаёт `(backend, write_transcript)`
- `_make_result()` / `_make_tfr()` — фабрики тестовых данных - `_make_result()` / `_make_backend()` — фабрики тестовых данных
## Частые задачи ## Частые задачи
+19 -2
View File
@@ -302,11 +302,28 @@ transcribe --uninstall-menu
| `--output` | `-o` | `<файл>-transcript.md` | Путь к выходному файлу | | `--output` | `-o` | `<файл>-transcript.md` | Путь к выходному файлу |
| `--device` | `-d` | `auto` | Устройство (auto, cpu, cuda, openvino, openvino-gpu, openvino-cpu, onnx) | | `--device` | `-d` | `auto` | Устройство (auto, cpu, cuda, openvino, openvino-gpu, openvino-cpu, onnx) |
| `--compute-type` | — | float16 (CUDA) / int8 (ONNX/OpenVINO) / float32 (CPU) | Тип вычислений | | `--compute-type` | — | float16 (CUDA) / int8 (ONNX/OpenVINO) / float32 (CPU) | Тип вычислений |
| `--threads` | `-t` | 0 (авто) | Потоки CPU (рекомендуется = число физ. ядер) | | `--threads` | `-t` | 0 (авто) | Потоки CPU (рекомендуется = число физ. ядер); для ONNX задаёт потоки и ASR, и VAD |
| `--diarize` / `--no-diarize` | — | `false` | Включить или отключить разделение на реплики говорящих | | `--diarize` / `--no-diarize` | — | `false` | Включить или отключить разделение на реплики говорящих |
| `--speakers` | — | авто | Ожидаемое число говорящих; включает диаризацию и несовместим с `--no-diarize` | | `--speakers` | — | авто | Ожидаемое число говорящих; включает диаризацию и несовместим с `--no-diarize` |
| `--force` | `-f` | — | Перезаписать существующие транскрипты | | `--force` | `-f` | — | Перезаписать существующие транскрипты |
| `--verbose` | `-v` | — | Подробный вывод | | `--verbose` | `-v` | — | Подробный вывод: сегменты по ходу, движок, потоки, версии runtime |
### Диагностика исполнения
Перед распознаванием программа печатает модель, устройство и тип вычислений,
которые выбраны фактически: после `auto` это `onnx`, после `openvino` —
`openvino-gpu` или `openvino-cpu`. Если после загрузки устройство оказалось
не тем, что выбрано до неё (например, OpenVINO взял CPU вместо GPU),
выводится строка `Запрошено ..., используется ...`.
С `--verbose` добавляются движок, бюджет потоков и версии runtime: для ONNX —
`onnxruntime`, `onnx-asr`, доступные providers и те, что заданы сессиям ASR
и VAD; для CUDA — `ctranslate2` и число видимых CUDA-устройств; для OpenVINO —
версия и доступные устройства. Этих строк достаточно, чтобы разобрать проблему
на чужой машине без доступа к записи: попросите прислать вывод с `--verbose`.
Наличие provider в списке доступных не означает, что распознавание идёт
на GPU или NPU: сессии получают только явно заданный список, сейчас это CPU.
## Платформы ## Платформы
+24 -16
View File
@@ -2,6 +2,7 @@
**Статус**: Принято **Статус**: Принято
**Дата**: 2026-03-18 **Дата**: 2026-03-18
**Обновлено**: 2026-09-21 — решения 2 и 4 пересмотрены (см. пометки)
## Контекст ## Контекст
@@ -17,19 +18,23 @@
**Почему**: загрузка модели (large-v3) занимает ~10 секунд и ~3 GB RAM/VRAM. Повторный запуск по уже обработанным файлам должен быть дешёвым no-op. **Почему**: загрузка модели (large-v3) занимает ~10 секунд и ~3 GB RAM/VRAM. Повторный запуск по уже обработанным файлам должен быть дешёвым no-op.
### 2. TranscribeFileResult: возврат обновлённого состояния модели ### 2. Состояние модели между файлами
При mid-stream CUDA fallback `_transcribe_file()` перезагружает модель на CPU внутри себя. Чтобы следующие файлы в батче не грузили модель повторно, результат включает обновлённые `model` и `actual_device`. **Пересмотрено 2026-09-21.** Исходное решение: `_transcribe_file()` возвращал
`TranscribeFileResult` с обновлёнными `model`, `backend`, `model_path`
и `actual_device`, а CLI переносил их в следующий файл после mid-stream
fallback. Это делало caller владельцем согласованности четырёх полей.
```python Теперь состояние не покидает module выполнения: `Transcriber` создаётся один
@dataclass раз на запуск после prescan, загружает модель при первом файле и сам держит
class TranscribeFileResult: загруженную модель, adapter и фактическое исполнение. Fallback меняет
result: TranscribeResult состояние внутри module, и следующий файл батча идёт уже по нему. Caller
model: WhisperModel # может измениться при fallback получает `TranscribeResult` и `ExecutionInfo` (запрошенное и выбранное
actual_device: str # может измениться при fallback исполнение, движок, модель, квантизация, потоки, версии runtime).
```
**Альтернатива**: передавать модель по ссылке через mutable контейнер — менее явно и сложнее тестировать. **Альтернатива**: mutable-контейнер с моделью, передаваемый по ссылке, —
отклонена тогда же; owner-объект решает ту же задачу явно и тестируется
через свой interface.
### 3. Конфиг: CWD → глобальный, без мержа ### 3. Конфиг: CWD → глобальный, без мержа
@@ -43,15 +48,18 @@ class TranscribeFileResult:
**Приоритет значений**: CLI > конфиг > хардкод. **Приоритет значений**: CLI > конфиг > хардкод.
### 4. `_transcribe_file()` — внутренний helper ### 4. Публичный `transcribe()` — путь одного файла
Публичный API (`transcribe()`) сохранён без изменений. Новая функция `_transcribe_file()` — внутренний helper с префиксом `_`, не часть публичного контракта. **Пересмотрено 2026-09-21.** Внутренние helpers `load_model()`
и `_transcribe_file()` удалены. `transcribe()` сохранён и стал обёрткой над
`transcribe()` стала тонкой обёрткой: `load_model()` + `_transcribe_file()` → `TranscribeResult`. тем же `Transcriber`: `model_name` и `compute_type` без значения берут
умолчания выбранного исполнения, `device="auto"` означает ONNX CPU, как в CLI.
## Последствия ## Последствия
- Обратная совместимость CLI: `transcribe file.mp4` работает как раньше - Обратная совместимость CLI: `transcribe file.mp4` работает как раньше
- Все существующие тесты проходят без изменений сигнатур - Batch mode: модель загружается один раз для всех файлов; CLI не переносит
- Batch mode: модель загружается один раз для всех файлов состояние между файлами
- Fallback из CLI недостижим: явный device — strict, `auto` — ONNX CPU без
переходов. Механизм сохранён для `transcribe(..., strict_device=False)`
- `--force` флаг для перезаписи существующих транскриптов в батч-режиме - `--force` флаг для перезаписи существующих транскриптов в батч-режиме
+27 -9
View File
@@ -44,21 +44,39 @@ CUDA bootstrap вызывается только при создании CUDA-м
- `onnx` → OnnxAsrBackend - `onnx` → OnnxAsrBackend
- `auto` → ONNX на CPU независимо от `nvidia-smi` и установки extra - `auto` → ONNX на CPU независимо от `nvidia-smi` и установки extra
### load_model() — единственный владелец pipeline ### Transcriber — единственный владелец загрузки и исполнения
`load_model()` выполняет ensure_model_available + create_model в одном вызове. Обновлено 2026-09-21. `Transcriber(ExecutionRequest)` в `transcriber.py`
CLI не вызывает ensure_model_available отдельно — это убирает двойной resolution разрешает `auto` и device-aware умолчания, выполняет ensure_model_available +
и гарантирует, что модель скачивается для правильного бэкенда. create_model в одном месте и держит модель, adapter и фактическое исполнение
на весь запуск. CLI не вызывает `get_backend` и не хранит handles.
Внутри module различаются три вещи: поддерживаемая модель, движок
распознавания (`faster-whisper`, `openvino`, `onnx-asr`) и способ исполнения.
Значение `device` из CLI/TOML сохраняется как вход: `get_backend` выбирает
по нему adapter, adapter называет свой движок атрибутом `engine`, а
аппаратную часть (CPU, CUDA, Intel GPU, список providers) трактует сам.
Новое исполнение — новое значение `device` в `get_backend` и его понимание
в adapter'е, не новая иерархия adapters.
### Cross-backend fallback ### Cross-backend fallback
Fallback живёт в `transcriber.py` (оркестратор), не в бэкендах: Fallback живёт внутри `Transcriber`, не в бэкендах и не в CLI:
- CUDA ошибка → CPU (FasterWhisper) - CUDA ошибка → CPU (FasterWhisper)
- OpenVINO ошибка → CPU (FasterWhisper) - OpenVINO ошибка → CPU (FasterWhisper)
- `strict_device=True` (явный device из CLI или TOML) → ошибка без fallback - strict запуск (явный device из CLI или TOML) → ошибка без fallback
При fallback в батч-режиме обновляются model, backend, model_path и actual_device После перехода module сам держит новое состояние; следующий файл батча
через TranscribeFileResult — следующий файл использует правильный бэкенд. использует его без участия caller. `WordTimestampsUnavailableError`
и ошибки пользовательских данных переходом не считаются.
### Диагностика исполнения
Каждый adapter отдаёт `runtime_info()` — версии runtime и фактическую
конфигурацию (для ONNX — доступные providers и те, что заданы сессиям ASR
и VAD, квантизация, бюджет потоков). `Transcriber` включает их в
`ExecutionInfo`; CLI печатает их в `--verbose`. Наличие provider в wheel не
считается доказательством выполнения графа на GPU/NPU.
### Аудио для OpenVINO ### Аудио для OpenVINO
@@ -83,7 +101,7 @@ macOS; ONNX обеспечивает автоматический CPU-путь
## Последствия ## Последствия
- Обратная совместимость: `transcribe()` сохранён; `load_model()` изменил сигнатуру (возвращает 4-tuple вместо 2-tuple, добавлен `compute_type_explicit`) - Обратная совместимость: `transcribe()` сохранён; `load_model()` и `_transcribe_file()` удалены (2026-09-21)
- Новый бэкенд добавляется одним файлом в `backends/` + регистрацией в `__init__.py` - Новый бэкенд добавляется одним файлом в `backends/` + регистрацией в `__init__.py`
- Модели скачиваются по запросу — CUDA пользователь не качает OpenVINO модели, и наоборот - Модели скачиваются по запросу — CUDA пользователь не качает OpenVINO модели, и наоборот
- ARM и macOS: OpenVINO не ставится (platform markers), auto использует ONNX - ARM и macOS: OpenVINO не ставится (platform markers), auto использует ONNX
@@ -1,8 +1,8 @@
# Владение выполнением транскрипции # Владение выполнением транскрипции
**Статус:** согласовано. Правила выбора исполнения и optional CUDA реализованы **Статус:** реализовано. Правила выбора исполнения и optional CUDA — в #2,
в #2; рефакторинг владения выполнением не начат. Windows GPU-прогон без Toolkit владение выполнением (`Transcriber`), бюджет потоков ONNX и диагностика —
остаётся невыполненным (см. ADR-001). в #56. Windows GPU-прогон без Toolkit остаётся невыполненным (см. ADR-001).
## Зачем ## Зачем
+7
View File
@@ -16,6 +16,9 @@ class Backend(Protocol):
наследование не требуется. наследование не требуется.
""" """
engine: str
"""Имя движка для сведений о выполнении."""
@property @property
def word_timestamps_available(self) -> bool: def word_timestamps_available(self) -> bool:
"""Гарантирует ли выбранный backend/model пословные таймкоды.""" """Гарантирует ли выбранный backend/model пословные таймкоды."""
@@ -53,3 +56,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",
@@ -43,10 +44,12 @@ MODEL_REQUIRED_FILES = [
class FasterWhisperBackend: class FasterWhisperBackend:
"""Бэкенд транскрипции через faster-whisper (CTranslate2).""" """Бэкенд транскрипции через faster-whisper (CTranslate2)."""
engine = "faster-whisper"
word_timestamps_available = True word_timestamps_available = True
def __init__(self): def __init__(self):
self.actual_compute_type: str | None = None self.actual_compute_type: str | None = None
self._cpu_threads = 0
def ensure_model_available( def ensure_model_available(
self, self,
@@ -94,6 +97,7 @@ class FasterWhisperBackend:
from faster_whisper import WhisperModel from faster_whisper import WhisperModel
self._cpu_threads = cpu_threads
try: try:
return WhisperModel( return WhisperModel(
model_path, model_path,
@@ -157,6 +161,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 {
"faster_whisper": package_version("faster-whisper"),
"ctranslate2": package_version("ctranslate2"),
"cuda_devices": cuda_devices,
"compute_type": self.actual_compute_type or "",
"cpu_threads": str(self._cpu_threads) if self._cpu_threads else "по умолчанию",
}
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:
+45 -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)
@@ -131,6 +132,8 @@ def _normalize_quantization(compute_type: str) -> str | None:
class OnnxAsrBackend: class OnnxAsrBackend:
"""Бэкенд транскрипции через onnx-asr (ONNX Runtime).""" """Бэкенд транскрипции через onnx-asr (ONNX Runtime)."""
engine = "onnx-asr"
def __init__(self, compute_type_explicit: bool = True): def __init__(self, compute_type_explicit: bool = True):
self._compute_type_explicit = compute_type_explicit self._compute_type_explicit = compute_type_explicit
self.actual_compute_type: str | None = None self.actual_compute_type: str | None = None
@@ -138,6 +141,9 @@ 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
self._cpu_threads = 0
@property @property
def word_timestamps_available(self) -> bool: def word_timestamps_available(self) -> bool:
@@ -191,21 +197,49 @@ class OnnxAsrBackend:
compute_type маппится в onnx-asr ``quantization`` — это суффикс файла compute_type маппится в onnx-asr ``quantization`` — это суффикс файла
модели; для unquantized (float32/fp32) нужно None, не строку. модели; для unquantized (float32/fp32) нужно None, не строку.
cpu_threads > 0 задаёт intra_op_num_threads сессиям ASR и VAD;
0 оставляет настройки потоков onnxruntime.
""" """
import onnx_asr import onnx_asr
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"]
session_kwargs: dict[str, Any] = {"providers": providers}
if cpu_threads > 0:
session_kwargs["sess_options"] = _session_options(cpu_threads)
model = onnx_asr.load_model( model = onnx_asr.load_model(
model=model_path, model=model_path,
quantization=quantization, quantization=quantization,
providers=["CPUExecutionProvider"], **session_kwargs,
) )
vad = onnx_asr.load_vad("silero", providers=["CPUExecutionProvider"]) vad = onnx_asr.load_vad("silero", **session_kwargs)
self._vad = vad self._vad = vad
self._providers = providers
self._quantization = quantization
self._cpu_threads = cpu_threads
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 {
"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",
"intra_op_threads": str(self._cpu_threads) if self._cpu_threads else "по умолчанию",
}
def transcribe( def transcribe(
self, self,
model: Any, model: Any,
@@ -314,6 +348,15 @@ class OnnxAsrBackend:
) )
def _session_options(cpu_threads: int) -> Any:
"""SessionOptions с бюджетом потоков; общий объект для ASR и VAD."""
import onnxruntime
options = onnxruntime.SessionOptions()
options.intra_op_num_threads = cpu_threads
return options
def _format_compute_types(quantizations: frozenset[str | None]) -> str: def _format_compute_types(quantizations: frozenset[str | None]) -> str:
values = [_compute_type_for_quantization(value) for value in quantizations] values = [_compute_type_for_quantization(value) for value in quantizations]
return ", ".join(sorted(values)) return ", ".join(sorted(values))
@@ -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] = {
@@ -56,6 +57,7 @@ MODEL_REQUIRED_FILES = [
class OpenVINOBackend: class OpenVINOBackend:
"""Бэкенд транскрипции через openvino-genai WhisperPipeline.""" """Бэкенд транскрипции через openvino-genai WhisperPipeline."""
engine = "openvino"
word_timestamps_available = True word_timestamps_available = True
def __init__( def __init__(
@@ -203,6 +205,18 @@ class OpenVINOBackend:
words=words, words=words,
) )
def runtime_info(self) -> dict[str, str]:
"""Версия OpenVINO, доступные устройства и выбранное фактически."""
from openvino import Core
return {
"openvino": package_version("openvino"),
"available_devices": ", ".join(Core().available_devices),
"device": self.actual_ov_device or "",
"compute_type": self.actual_compute_type or "",
"threads": "управляет OpenVINO, --threads не применяется",
}
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.
+80 -184
View File
@@ -13,8 +13,6 @@ from rich.status import Status
from .config import ( from .config import (
CliValues, CliValues,
ResolvedConfig,
apply_device_defaults,
load_config, load_config,
resolve_defaults, resolve_defaults,
) )
@@ -39,11 +37,12 @@ from .quality import (
) )
from .speaker_diarizer import SpeakerDiarizer, load_speaker_diarizer from .speaker_diarizer import SpeakerDiarizer, load_speaker_diarizer
from .transcriber import ( from .transcriber import (
ExecutionInfo,
ExecutionRequest,
Segment, Segment,
Transcriber,
TranscribeResult, TranscribeResult,
_transcribe_file,
cuda_error_hint, cuda_error_hint,
load_model,
) )
from .types import ( from .types import (
UNKNOWN_LANGUAGE, UNKNOWN_LANGUAGE,
@@ -53,10 +52,7 @@ from .types import (
) )
from .utils import ( from .utils import (
build_output_path, build_output_path,
detect_device,
expand_globs, expand_globs,
get_gpu_name,
get_intel_gpu_name,
has_existing_transcript, has_existing_transcript,
validate_input_file, validate_input_file,
) )
@@ -95,21 +91,6 @@ def _with_cli_warning_renderer(
return wrapped return wrapped
def _format_device_info(device_used: str) -> str:
"""Формирует строку устройства для шапки транскрипта."""
if device_used == "cuda":
gpu_name = get_gpu_name()
return f"CUDA ({gpu_name or 'Unknown GPU'})"
if device_used == "openvino-gpu":
gpu_name = get_intel_gpu_name()
return f"OpenVINO ({gpu_name or 'Intel GPU'})"
if device_used in ("openvino", "openvino-cpu"):
return "OpenVINO (CPU)"
if device_used == "onnx":
return "ONNX (CPU)"
return "CPU"
def _format_language_mode(requested_language: str, result: TranscribeResult) -> str: def _format_language_mode(requested_language: str, result: TranscribeResult) -> str:
"""Описывает источник языка, не выдавая профиль модели за детектор.""" """Описывает источник языка, не выдавая профиль модели за детектор."""
if requested_language != "auto": if requested_language != "auto":
@@ -348,7 +329,7 @@ def main(
) )
raise SystemExit(2) raise SystemExit(2)
resolved_device: str | None = None requested_device: str | None = None
try: try:
config = load_config() config = load_config()
cli_values: CliValues = { cli_values: CliValues = {
@@ -360,11 +341,17 @@ def main(
} }
defaults = resolve_defaults(cli_values, config) defaults = resolve_defaults(cli_values, config)
diarize_enabled = defaults["diarize"] is True or speakers is not None diarize_enabled = defaults["diarize"] is True or speakers is not None
requested_device = defaults["device"]
resolved_device = detect_device(defaults["device"]) # Умолчания model/compute_type зависят от устройства — их разрешает module.
defaults = apply_device_defaults(defaults, resolved_device, cli_values, config) request = ExecutionRequest(
device=requested_device,
ct_explicit = compute_type is not None or "compute_type" in config model=defaults["model"],
compute_type=defaults["compute_type"],
language=defaults["language"],
cpu_threads=threads,
require_word_timestamps=diarize_enabled,
)
expanded = expand_globs(files) expanded = expand_globs(files)
if not expanded: if not expanded:
@@ -381,22 +368,18 @@ def main(
if is_batch: if is_batch:
_run_batch( _run_batch(
expanded, expanded,
defaults, request,
verbose, verbose,
force, force,
ct_explicit,
cpu_threads=threads,
diarize=diarize_enabled, diarize=diarize_enabled,
speakers=speakers, speakers=speakers,
) )
else: else:
_run_single( _run_single(
expanded[0], expanded[0],
defaults, request,
output, output,
verbose, verbose,
ct_explicit,
cpu_threads=threads,
diarize=diarize_enabled, diarize=diarize_enabled,
speakers=speakers, speakers=speakers,
) )
@@ -406,15 +389,15 @@ def main(
except SystemExit: except SystemExit:
raise raise
except ValueError as exc: except ValueError as exc:
_print_cuda_hint(exc, resolved_device) _print_cuda_hint(exc, requested_device)
console.print(f"Ошибка: {exc}", style="red bold") console.print(f"Ошибка: {exc}", style="red bold")
raise SystemExit(1) raise SystemExit(1)
except (FileNotFoundError,) as exc: except (FileNotFoundError,) as exc:
_print_cuda_hint(exc, resolved_device) _print_cuda_hint(exc, requested_device)
console.print(f"Ошибка: {exc}", style="red bold") console.print(f"Ошибка: {exc}", style="red bold")
raise SystemExit(1) raise SystemExit(1)
except Exception as exc: except Exception as exc:
_print_cuda_hint(exc, resolved_device) _print_cuda_hint(exc, requested_device)
if verbose: if verbose:
console.print_exception() console.print_exception()
else: else:
@@ -423,13 +406,50 @@ def main(
raise SystemExit(1) raise SystemExit(1)
def _print_execution_header(info: ExecutionInfo, verbose: bool) -> None:
"""Печатает выбранное исполнение; в --verbose — версии runtime для диагностики."""
console.print(
f"Модель: [bold]{info.model}[/bold] "
f"Устройство: [bold]{info.device}[/bold] "
f"Compute: [bold]{info.compute_type}[/bold]"
)
if info.device == "openvino-gpu" and info.model != "large-v3":
console.print(
"Совет: --model large-v3 даёт лучшее качество на GPU (~2x дольше)",
style="dim",
)
if info.device != info.resolved_device:
console.print(
f"Запрошено {info.requested_device}, используется {info.device}",
style="yellow",
)
if verbose:
threads = info.cpu_threads or "по умолчанию библиотеки"
console.print(
f"Движок: {info.engine} Потоки (запрошено): {threads}", style="dim"
)
for key, value in info.runtime.items():
console.print(f" {key}: {value}", style="dim", markup=False)
def _load_diarizer(
diarize: bool, speakers: int | None, cpu_threads: int
) -> SpeakerDiarizer | None:
"""Готовит диаризатор до первого ASR; пословный контракт уже проверен module."""
if not diarize:
return None
return load_speaker_diarizer(
speakers=speakers,
threads=cpu_threads,
on_status=lambda message: console.print(message),
)
def _run_single( def _run_single(
file: Path, file: Path,
defaults: ResolvedConfig, request: ExecutionRequest,
output: Path | None, output: Path | None,
verbose: bool, verbose: bool,
compute_type_explicit: bool = False,
cpu_threads: int = 0,
diarize: bool = False, diarize: bool = False,
speakers: int | None = None, speakers: int | None = None,
) -> None: ) -> None:
@@ -437,9 +457,6 @@ def _run_single(
start = time.monotonic() start = time.monotonic()
validated_file = validate_input_file(file) validated_file = validate_input_file(file)
requested_device = defaults["device"]
resolved_device = detect_device(requested_device)
strict = requested_device != "auto"
output_path = build_output_path(validated_file, output) output_path = build_output_path(validated_file, output)
console.print(f"Файл: [bold]{validated_file.name}[/bold]") console.print(f"Файл: [bold]{validated_file.name}[/bold]")
@@ -447,59 +464,18 @@ def _run_single(
def on_segment(seg: Segment) -> None: def on_segment(seg: Segment) -> None:
console.print(f" [{seg.start:.2f}s] {seg.text.strip()}") console.print(f" [{seg.start:.2f}s] {seg.text.strip()}")
model_obj, actual_device, backend, model_path = load_model( transcriber = Transcriber(request)
defaults["model"], info = transcriber.prepare(on_status=lambda msg: console.print(msg))
resolved_device, _print_execution_header(info, verbose)
defaults["compute_type"], speaker_diarizer = _load_diarizer(diarize, speakers, request.cpu_threads)
on_status=lambda msg: console.print(msg),
strict_device=strict,
compute_type_explicit=compute_type_explicit,
cpu_threads=cpu_threads,
)
actual_ct = (
getattr(backend, "actual_compute_type", defaults["compute_type"])
or defaults["compute_type"]
)
console.print(
f"Модель: [bold]{defaults['model']}[/bold] "
f"Устройство: [bold]{actual_device}[/bold] "
f"Compute: [bold]{actual_ct}[/bold]"
)
if actual_device == "openvino-gpu" and defaults["model"] != "large-v3":
console.print(
"Совет: --model large-v3 даёт лучшее качество на GPU (~2x дольше)",
style="dim",
)
speaker_diarizer = None
if diarize:
if not backend.word_timestamps_available:
raise ValueError(
"Выбранный движок или модель не поддерживает пословные таймкоды"
)
speaker_diarizer = load_speaker_diarizer(
speakers=speakers,
threads=cpu_threads,
on_status=lambda message: console.print(message),
)
with Status("Подготавливаю запуск...", console=console) as status: with Status("Подготавливаю запуск...", console=console) as status:
tfr = _transcribe_file( result = transcriber.transcribe(
model=model_obj, validated_file,
actual_device=actual_device,
backend=backend,
model_path=model_path,
file_path=validated_file,
model_name=defaults["model"],
compute_type=defaults["compute_type"],
language=defaults["language"] if defaults["language"] != "auto" else None,
on_segment=on_segment if verbose else None, on_segment=on_segment if verbose else None,
on_status=status.update, on_status=status.update,
strict_device=strict,
cpu_threads=cpu_threads,
) )
result = tfr.result
speaker_transcript = None speaker_transcript = None
diarization_warning = None diarization_warning = None
diarization_degraded = False diarization_degraded = False
@@ -526,33 +502,19 @@ def _run_single(
if diarization_warning is not None: if diarization_warning is not None:
console.print(f"Внимание: {diarization_warning}", style="yellow") console.print(f"Внимание: {diarization_warning}", style="yellow")
if tfr.actual_device != resolved_device:
if requested_device == "auto":
console.print(
f"Определено устройство {resolved_device}, "
f"но использовано {tfr.actual_device} (fallback)",
style="yellow",
)
else:
console.print(
f"Запрошено {requested_device}, использовано {tfr.actual_device}",
style="yellow",
)
if len(result.segments) == 0: if len(result.segments) == 0:
message = f"Речь не обнаружена в файле {validated_file.name}" message = f"Речь не обнаружена в файле {validated_file.name}"
if speaker_diarizer is not None: if speaker_diarizer is not None:
message += "; диаризация не запускалась" message += "; диаризация не запускалась"
console.print(message, style="yellow") console.print(message, style="yellow")
device_info = _format_device_info(result.device_used) language_mode = _format_language_mode(request.language or "auto", result)
language_mode = _format_language_mode(defaults["language"], result)
content = format_transcript( content = format_transcript(
result=result, result=result,
source_filename=validated_file.name, source_filename=validated_file.name,
model_name=defaults["model"], model_name=info.model,
device_info=device_info, device_info=info.description,
language_mode=language_mode, language_mode=language_mode,
speaker_transcript=speaker_transcript, speaker_transcript=speaker_transcript,
diarization_warning=diarization_warning, diarization_warning=diarization_warning,
@@ -569,11 +531,9 @@ def _run_single(
def _run_batch( def _run_batch(
files: list[Path], files: list[Path],
defaults: ResolvedConfig, request: ExecutionRequest,
verbose: bool, verbose: bool,
force: bool, force: bool,
compute_type_explicit: bool = False,
cpu_threads: int = 0,
diarize: bool = False, diarize: bool = False,
speakers: int | None = None, speakers: int | None = None,
) -> None: ) -> None:
@@ -603,50 +563,11 @@ def _run_batch(
raise SystemExit(1) raise SystemExit(1)
return return
# Phase 2: Load model (ensure + create в одном вызове) # Phase 2: module выполнения создаётся, когда есть работа
requested_device = defaults["device"] transcriber = Transcriber(request)
resolved_device = detect_device(requested_device) info = transcriber.prepare(on_status=lambda msg: console.print(msg))
strict = requested_device != "auto" _print_execution_header(info, verbose)
model_obj, actual_device, backend, model_path = load_model( speaker_diarizer = _load_diarizer(diarize, speakers, request.cpu_threads)
defaults["model"],
resolved_device,
defaults["compute_type"],
on_status=lambda msg: console.print(msg),
strict_device=strict,
compute_type_explicit=compute_type_explicit,
cpu_threads=cpu_threads,
)
if actual_device == "openvino-gpu" and defaults["model"] != "large-v3":
console.print(
"Совет: --model large-v3 даёт лучшее качество на GPU (~2x дольше)",
style="dim",
)
if actual_device != resolved_device:
if requested_device == "auto":
console.print(
f"Определено устройство {resolved_device}, "
f"но используется {actual_device} (fallback)",
style="yellow",
)
else:
console.print(
f"Запрошено {requested_device}, используется {actual_device}",
style="yellow",
)
speaker_diarizer = None
if diarize:
if not backend.word_timestamps_available:
raise ValueError(
"Выбранный движок или модель не поддерживает пословные таймкоды"
)
speaker_diarizer = load_speaker_diarizer(
speakers=speakers,
threads=cpu_threads,
on_status=lambda message: console.print(message),
)
# Phase 3: Transcribe # Phase 3: Transcribe
processed = 0 processed = 0
@@ -664,38 +585,15 @@ def _run_batch(
console.print(f" [{seg.start:.2f}s] {seg.text.strip()}") console.print(f" [{seg.start:.2f}s] {seg.text.strip()}")
with Status(f"{prefix}...", console=console) as status: with Status(f"{prefix}...", console=console) as status:
tfr = _transcribe_file( result = transcriber.transcribe(
model=model_obj, file,
actual_device=actual_device,
backend=backend,
model_path=model_path,
file_path=file,
model_name=defaults["model"],
compute_type=defaults["compute_type"],
language=defaults["language"]
if defaults["language"] != "auto"
else None,
on_segment=on_segment if verbose else None, on_segment=on_segment if verbose else None,
on_status=status.update on_status=status.update
if not verbose if not verbose
else lambda msg: console.print(msg), else lambda msg: console.print(msg),
strict_device=strict,
cpu_threads=cpu_threads,
) )
if tfr.actual_device != actual_device: language_mode = _format_language_mode(request.language or "auto", result)
console.print(
f" {file.name}: fallback на {tfr.actual_device} при транскрипции",
style="yellow",
)
# Обновляем после возможного mid-stream fallback
model_obj = tfr.model
actual_device = tfr.actual_device
backend = tfr.backend
model_path = tfr.model_path
result = tfr.result
language_mode = _format_language_mode(defaults["language"], result)
speaker_transcript = None speaker_transcript = None
diarization_warning = None diarization_warning = None
file_degraded = False file_degraded = False
@@ -735,13 +633,11 @@ def _run_batch(
message += "; диаризация не запускалась" message += "; диаризация не запускалась"
console.print(message, style="yellow") console.print(message, style="yellow")
device_info = _format_device_info(result.device_used)
content = format_transcript( content = format_transcript(
result=result, result=result,
source_filename=file.name, source_filename=file.name,
model_name=defaults["model"], model_name=info.model,
device_info=device_info, device_info=info.description,
language_mode=language_mode, language_mode=language_mode,
speaker_transcript=speaker_transcript, speaker_transcript=speaker_transcript,
diarization_warning=diarization_warning, diarization_warning=diarization_warning,
@@ -760,7 +656,7 @@ def _run_batch(
except KeyboardInterrupt: except KeyboardInterrupt:
raise raise
except Exception as exc: except Exception as exc:
_print_cuda_hint(exc, actual_device) _print_cuda_hint(exc, request.device)
if verbose: if verbose:
console.print_exception() console.print_exception()
else: else:
+15 -30
View File
@@ -30,12 +30,16 @@ class CliValues(TypedDict, total=False):
class ResolvedConfig(TypedDict): class ResolvedConfig(TypedDict):
"""Полная конфигурация после разрешения каскада.""" """Конфигурация после каскада CLI > TOML.
model: str ``model`` и ``compute_type`` остаются ``None``, если не заданы явно:
их умолчания зависят от устройства и разрешаются при загрузке модели.
"""
model: str | None
language: str language: str
device: str device: str
compute_type: str compute_type: str | None
diarize: bool diarize: bool
@@ -47,6 +51,8 @@ HARDCODED_DEFAULTS: ResolvedConfig = {
"diarize": False, "diarize": False,
} }
# Умолчания по устройству; HARDCODED_DEFAULTS — последний резерв для
# устройств вне таблицы.
DEVICE_DEFAULTS: dict[str, dict[str, str]] = { DEVICE_DEFAULTS: dict[str, dict[str, str]] = {
"cuda": {"model": "medium", "compute_type": "float16"}, "cuda": {"model": "medium", "compute_type": "float16"},
"cpu": {"model": "medium", "compute_type": "float32"}, "cpu": {"model": "medium", "compute_type": "float32"},
@@ -136,11 +142,12 @@ def load_config(path: Path | None = None) -> ConfigValues:
def resolve_defaults(cli_values: CliValues, config: ConfigValues) -> ResolvedConfig: def resolve_defaults(cli_values: CliValues, config: ConfigValues) -> ResolvedConfig:
"""Каскад приоритетов: CLI > конфиг-файл > hardcoded-дефолты.""" """Каскад приоритетов: CLI > конфиг-файл > hardcoded-дефолты.
Device-aware ключи (model, compute_type) без явного значения остаются None.
"""
return { return {
"model": _resolve_value( "model": _resolve_value(cli_values.get("model"), config.get("model"), None),
cli_values.get("model"), config.get("model"), HARDCODED_DEFAULTS["model"]
),
"language": _resolve_value( "language": _resolve_value(
cli_values.get("language"), cli_values.get("language"),
config.get("language"), config.get("language"),
@@ -152,9 +159,7 @@ def resolve_defaults(cli_values: CliValues, config: ConfigValues) -> ResolvedCon
HARDCODED_DEFAULTS["device"], HARDCODED_DEFAULTS["device"],
), ),
"compute_type": _resolve_value( "compute_type": _resolve_value(
cli_values.get("compute_type"), cli_values.get("compute_type"), config.get("compute_type"), None
config.get("compute_type"),
HARDCODED_DEFAULTS["compute_type"],
), ),
"diarize": _resolve_value( "diarize": _resolve_value(
cli_values.get("diarize"), cli_values.get("diarize"),
@@ -171,23 +176,3 @@ def _resolve_value(cli_value: _T | None, config_value: _T | None, default: _T) -
if config_value is not None: if config_value is not None:
return config_value return config_value
return default return default
def apply_device_defaults(
defaults: ResolvedConfig,
resolved_device: str,
cli_values: CliValues,
config: ConfigValues,
) -> ResolvedConfig:
"""Применяет device-aware дефолты для model и compute_type,
если они не были явно заданы через CLI или конфиг."""
device_defs = DEVICE_DEFAULTS.get(resolved_device, {})
if not device_defs:
return defaults
result = dict(defaults)
for key in ("model", "compute_type"):
if cli_values.get(key) is None and key not in config:
if key in device_defs:
result[key] = device_defs[key]
return cast(ResolvedConfig, result)
+198 -125
View File
@@ -2,170 +2,245 @@
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,
TranscribeResult, TranscribeResult,
WordTimestampsUnavailableError, WordTimestampsUnavailableError,
) )
from local_transcriber.utils import detect_device, get_gpu_name, get_intel_gpu_name
def load_model( @dataclass(frozen=True)
model_name: str, class ExecutionRequest:
device: str, """Запрошенные настройки запуска до разрешения умолчаний.
compute_type: str,
on_status: Callable[[str], None] | None = None,
strict_device: bool = False,
compute_type_explicit: bool = False,
cpu_threads: int = 0,
) -> tuple[Any, str, Any, str]:
"""Загружает модель: ensure + create с fallback.
Возвращает (model, actual_device, backend, model_path). ``None`` в model/compute_type означает device-aware умолчание;
compute_type_explicit: True если пользователь явно указал --compute-type. ``strict_device=None`` — strict для любого явного device, кроме ``auto``.
cpu_threads: число потоков для CPU inference (0 = дефолт библиотеки).
""" """
backend = get_backend(device, compute_type_explicit=compute_type_explicit)
actual_device = device
model_path = backend.ensure_model_available(model_name, compute_type, on_status) 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
try:
_notify_status(on_status, f"Инициализирую модель на {device}...") @dataclass(frozen=True)
model = backend.create_model( class ExecutionInfo:
model_path, device, compute_type, cpu_threads=cpu_threads """Сведения о выполнении: что запрошено, что выбрано и чем выполняется."""
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"
) )
# Резолвим actual_device по реальному OpenVINO device self._compute_type_explicit = request.compute_type is not None
ov_dev = getattr(backend, "actual_ov_device", None) self._resolved_device = detect_device(request.device)
if ov_dev == "GPU" and actual_device != "openvino-gpu": self._device = self._resolved_device
actual_device = "openvino-gpu" self._backend: Any = None
elif ( self._model: Any = None
ov_dev == "CPU" self._info: ExecutionInfo | None = None
and actual_device.startswith("openvino")
and actual_device != "openvino-cpu" @property
): def execution(self) -> ExecutionInfo:
actual_device = "openvino-cpu" """Текущие сведения о выполнении; доступны после prepare()."""
except (RuntimeError, ValueError) as exc: if self._info is None:
if device != "cpu" and _is_backend_error(exc, device): raise RuntimeError("Модель ещё не подготовлена: вызовите prepare()")
if strict_device: return self._info
def prepare(self, on_status: Callable[[str], None] | None = None) -> ExecutionInfo:
"""Загружает модель и проверяет возможности до первого файла. Идемпотентно."""
if self._info is not None:
return self._info
try:
info = self._load(self._device, on_status)
except (RuntimeError, ValueError) as exc:
if not self._may_fall_back(exc):
raise raise
warnings.warn( warnings.warn(
f"Не удалось загрузить модель на {device}: {exc}. Переключение на CPU.", f"Не удалось загрузить модель на {self._device}: {exc}. "
"Переключение на CPU.",
stacklevel=2, stacklevel=2,
) )
actual_device = "cpu" info = self._load("cpu", on_status)
backend = get_backend("cpu") if self._request.require_word_timestamps and not info.word_timestamps_available:
model_path = backend.ensure_model_available( raise ValueError(
model_name, compute_type, on_status "Выбранный движок или модель не поддерживает пословные таймкоды"
) )
_notify_status(on_status, "Инициализирую модель на cpu...") self._info = info
model = backend.create_model( return info
model_path, "cpu", compute_type, cpu_threads=cpu_threads
)
else:
raise
return model, actual_device, backend, model_path def transcribe(
self,
file_path: Path,
def _transcribe_file( on_segment: Callable[[Segment], None] | None = None,
model: Any, on_status: Callable[[str], None] | None = None,
actual_device: str, ) -> TranscribeResult:
backend: Any, """Транскрибирует один файл; при разрешённом fallback перезагружает модель."""
model_path: str, self.prepare(on_status)
file_path: Path, lang_arg = self._request.language or None
model_name: str, if lang_arg == "auto":
compute_type: str, lang_arg = None
language: str | None = None, try:
on_segment: Callable[[Segment], None] | None = None, result = self._recognize(file_path, lang_arg, on_segment, on_status)
on_status: Callable[[str], None] | None = None, except (RuntimeError, ValueError) as exc:
strict_device: bool = False, if not self._may_fall_back(exc):
cpu_threads: int = 0,
) -> TranscribeFileResult:
"""Транскрибирует один файл. При mid-stream fallback перезагружает модель."""
lang_arg = language if language and language != "auto" else None
try:
_notify_status(on_status, "Транскрибирую...")
result = backend.transcribe(model, file_path, lang_arg, on_segment, on_status)
result.device_used = actual_device
except (RuntimeError, ValueError) as exc:
if actual_device != "cpu" and _is_backend_error(exc, actual_device):
if strict_device:
raise raise
warnings.warn( warnings.warn(
f"Ошибка при транскрипции на {actual_device}: {exc}. " f"Ошибка при транскрипции на {self._device}: {exc}. "
"Переключение на CPU и повтор.", "Переключение на CPU и повтор.",
stacklevel=2, stacklevel=2,
) )
actual_device = "cpu" self._info = self._load("cpu", on_status)
backend = get_backend("cpu") result = self._recognize(file_path, lang_arg, on_segment, on_status)
model_path = backend.ensure_model_available( return result
model_name, compute_type, on_status
)
_notify_status(on_status, "Инициализирую модель на cpu...")
model = backend.create_model(
model_path, "cpu", compute_type, cpu_threads=cpu_threads
)
_notify_status(on_status, "Транскрибирую...")
result = backend.transcribe(
model, file_path, lang_arg, on_segment, on_status
)
result.device_used = actual_device
else:
raise
return TranscribeFileResult( def _recognize(
result=result, self,
model=model, file_path: Path,
actual_device=actual_device, language: str | None,
backend=backend, on_segment: Callable[[Segment], None] | None,
model_path=model_path, 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
) -> ExecutionInfo:
"""Загружает модель на device; неявные model/compute_type берутся из его умолчаний."""
defaults = DEVICE_DEFAULTS.get(device, {})
model_name = self._request.model or defaults.get(
"model", HARDCODED_DEFAULTS["model"]
)
compute_type = self._request.compute_type or defaults.get(
"compute_type", HARDCODED_DEFAULTS["compute_type"]
)
backend = get_backend(device, compute_type_explicit=self._compute_type_explicit)
model_path = backend.ensure_model_available(model_name, compute_type, on_status)
_notify_status(on_status, f"Инициализирую модель на {device}...")
model = backend.create_model(
model_path,
device,
compute_type,
cpu_threads=self._request.cpu_threads,
)
self._backend = backend
self._model = model
self._device = _refine_openvino_device(device, backend)
return ExecutionInfo(
requested_device=self._request.device,
resolved_device=self._resolved_device,
device=self._device,
engine=backend.engine,
model=model_name,
compute_type=getattr(backend, "actual_compute_type", None) or compute_type,
cpu_threads=self._request.cpu_threads,
word_timestamps_available=bool(backend.word_timestamps_available),
description=_describe_device(self._device),
runtime=_collect_runtime_info(backend),
)
def _collect_runtime_info(backend: Any) -> dict[str, str]:
"""Диагностика не должна ронять запуск: сбой попадает в сведения строкой."""
try:
return dict(backend.runtime_info())
except Exception as exc: # noqa: BLE001
return {"diagnostics": f"недоступно ({exc})"}
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 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(
@@ -175,8 +250,6 @@ def ensure_model_available(
on_status: Callable[[str], None] | None = None, on_status: Callable[[str], None] | None = None,
) -> str: ) -> str:
"""Публичный helper: гарантирует наличие модели для указанного бэкенда.""" """Публичный helper: гарантирует наличие модели для указанного бэкенда."""
from local_transcriber.config import DEVICE_DEFAULTS, HARDCODED_DEFAULTS
if compute_type is None: if compute_type is None:
device_defs = DEVICE_DEFAULTS.get(device, {}) device_defs = DEVICE_DEFAULTS.get(device, {})
compute_type = device_defs.get( compute_type = device_defs.get(
-10
View File
@@ -2,7 +2,6 @@
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any
# Единый признак «язык неизвестен» для всех бэкендов # Единый признак «язык неизвестен» для всех бэкендов
UNKNOWN_LANGUAGE = "unknown" UNKNOWN_LANGUAGE = "unknown"
@@ -83,14 +82,5 @@ class TranscribeResult:
words: list[Word] = field(default_factory=list) words: list[Word] = field(default_factory=list)
@dataclass
class TranscribeFileResult:
result: TranscribeResult
model: Any # backend-specific model handle
actual_device: str
backend: Any = None # backend instance (для переиспользования в батче)
model_path: str = "" # путь к модели (меняется при cross-backend fallback)
StatusCallback = Callable[[str], None] | None StatusCallback = Callable[[str], None] | None
SegmentCallback = Callable[[Segment], None] | None SegmentCallback = Callable[[Segment], None] | None
+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["ctranslate2"]
assert info["faster_whisper"]
assert info["cuda_devices"] == "0"
assert info["compute_type"] == "float32"
assert info["cpu_threads"] == "по умолчанию"
+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["openvino"]
assert info["available_devices"] == "CPU, GPU"
assert info["device"] == "GPU"
assert info["compute_type"] == "fp16"
assert "--threads не применяется" in info["threads"]
+261 -907
View File
File diff suppressed because it is too large. Load diff
+3 -76
View File
@@ -3,7 +3,6 @@ from unittest.mock import patch
import pytest import pytest
from local_transcriber.config import ( from local_transcriber.config import (
apply_device_defaults,
find_config_file, find_config_file,
load_config, load_config,
resolve_defaults, resolve_defaults,
@@ -107,15 +106,15 @@ def test_resolve_defaults_config_wins():
assert result["model"] == "tiny" assert result["model"] == "tiny"
def test_resolve_defaults_hardcoded_fallback(): def test_resolve_defaults_leaves_device_aware_keys_empty():
result = resolve_defaults( result = resolve_defaults(
{"model": None, "language": None, "device": None, "compute_type": None}, {} {"model": None, "language": None, "device": None, "compute_type": None}, {}
) )
assert result == { assert result == {
"model": "medium", "model": None,
"language": "ru", "language": "ru",
"device": "auto", "device": "auto",
"compute_type": "float32", "compute_type": None,
"diarize": False, "diarize": False,
} }
@@ -136,54 +135,6 @@ def test_resolve_defaults_diarize_priority(cli_value, config_value, expected):
assert resolve_defaults(cli, config)["diarize"] is expected assert resolve_defaults(cli, config)["diarize"] is expected
def test_apply_device_defaults_cuda():
defaults = {"model": "medium", "language": "ru", "device": "auto", "compute_type": "float32"}
cli = {"model": None, "language": None, "device": None, "compute_type": None}
result = apply_device_defaults(defaults, "cuda", cli, {})
assert result["model"] == "medium"
assert result["compute_type"] == "float16"
def test_apply_device_defaults_cpu():
defaults = {"model": "medium", "language": "ru", "device": "auto", "compute_type": "float32"}
cli = {"model": None, "language": None, "device": None, "compute_type": None}
result = apply_device_defaults(defaults, "cpu", cli, {})
assert result["model"] == "medium"
assert result["compute_type"] == "float32"
def test_apply_device_defaults_onnx_uses_readable_model():
defaults = {
"model": "medium",
"language": "ru",
"device": "auto",
"compute_type": "float32",
}
cli = {"model": None, "language": None, "device": None, "compute_type": None}
result = apply_device_defaults(defaults, "onnx", cli, {})
assert result["model"] == "gigaam-v3-e2e-rnnt"
assert result["compute_type"] == "int8"
def test_apply_device_defaults_cli_overrides():
defaults = {"model": "large-v3", "language": "ru", "device": "auto", "compute_type": "int8"}
cli = {"model": "large-v3", "language": None, "device": None, "compute_type": "int8"}
result = apply_device_defaults(defaults, "cuda", cli, {})
assert result["model"] == "large-v3"
assert result["compute_type"] == "int8"
def test_apply_device_defaults_config_overrides():
defaults = {"model": "small", "language": "ru", "device": "auto", "compute_type": "int8"}
cli = {"model": None, "language": None, "device": None, "compute_type": None}
config = {"model": "small", "compute_type": "int8"}
result = apply_device_defaults(defaults, "cuda", cli, config)
assert result["model"] == "small"
assert result["compute_type"] == "int8"
def test_load_config_openvino_device(tmp_path): def test_load_config_openvino_device(tmp_path):
config = tmp_path / "config.toml" config = tmp_path / "config.toml"
config.write_text('device = "openvino"\n') config.write_text('device = "openvino"\n')
@@ -203,27 +154,3 @@ def test_load_config_openvino_cpu_device(tmp_path):
config.write_text('device = "openvino-cpu"\n') config.write_text('device = "openvino-cpu"\n')
result = load_config(config) result = load_config(config)
assert result == {"device": "openvino-cpu"} assert result == {"device": "openvino-cpu"}
def test_apply_device_defaults_openvino():
defaults = {"model": "medium", "language": "ru", "device": "auto", "compute_type": "float32"}
cli = {"model": None, "language": None, "device": None, "compute_type": None}
result = apply_device_defaults(defaults, "openvino", cli, {})
assert result["model"] == "medium"
assert result["compute_type"] == "int8"
def test_apply_device_defaults_openvino_gpu():
defaults = {"model": "medium", "language": "ru", "device": "auto", "compute_type": "float32"}
cli = {"model": None, "language": None, "device": None, "compute_type": None}
result = apply_device_defaults(defaults, "openvino-gpu", cli, {})
assert result["model"] == "medium"
assert result["compute_type"] == "int8"
def test_apply_device_defaults_openvino_cpu():
defaults = {"model": "medium", "language": "ru", "device": "auto", "compute_type": "float32"}
cli = {"model": None, "language": None, "device": None, "compute_type": None}
result = apply_device_defaults(defaults, "openvino-cpu", cli, {})
assert result["model"] == "medium"
assert result["compute_type"] == "int8"
+53
View File
@@ -108,6 +108,36 @@ class TestCreateModel:
) )
load_vad.assert_called_once_with("silero", providers=["CPUExecutionProvider"]) load_vad.assert_called_once_with("silero", providers=["CPUExecutionProvider"])
def test_thread_budget_reaches_asr_and_vad_sessions(self, monkeypatch):
"""--threads задаёт intra_op_num_threads обеим сессиям через SessionOptions."""
load_asr = MagicMock()
load_vad = MagicMock()
monkeypatch.setattr("onnx_asr.load_model", load_asr)
monkeypatch.setattr("onnx_asr.load_vad", load_vad)
backend = OnnxAsrBackend()
backend.create_model("gigaam-v3-e2e-rnnt", "onnx", "int8", cpu_threads=4)
asr_options = load_asr.call_args.kwargs["sess_options"]
vad_options = load_vad.call_args.kwargs["sess_options"]
assert asr_options.intra_op_num_threads == 4
assert vad_options.intra_op_num_threads == 4
assert backend.runtime_info()["intra_op_threads"] == "4"
def test_zero_thread_budget_keeps_library_session_defaults(self, monkeypatch):
"""0 не передаёт SessionOptions: потоки остаются на усмотрение onnxruntime."""
load_asr = MagicMock()
load_vad = MagicMock()
monkeypatch.setattr("onnx_asr.load_model", load_asr)
monkeypatch.setattr("onnx_asr.load_vad", load_vad)
backend = OnnxAsrBackend()
backend.create_model("gigaam-v3-e2e-rnnt", "onnx", "int8", cpu_threads=0)
assert "sess_options" not in load_asr.call_args.kwargs
assert "sess_options" not in load_vad.call_args.kwargs
assert backend.runtime_info()["intra_op_threads"] == "по умолчанию"
def test_wraps_vad_model_with_timestamps(self, monkeypatch): def test_wraps_vad_model_with_timestamps(self, monkeypatch):
timestamped_model = object() timestamped_model = object()
@@ -659,3 +689,26 @@ 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["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"
+455 -294
View File
@@ -4,12 +4,12 @@ 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,
cuda_error_hint, cuda_error_hint,
ensure_model_available, ensure_model_available,
load_model,
transcribe, transcribe,
) )
from local_transcriber.types import WordTimestampsUnavailableError from local_transcriber.types import WordTimestampsUnavailableError
@@ -232,7 +232,7 @@ def test_transcribe_reports_status_transitions(mock_get_backend):
on_status=statuses.append, on_status=statuses.append,
) )
# load_model reports init status, _transcribe_file reports transcribe status # Статусы подготовки и распознавания идут через один callback
assert any("Инициализирую модель" in s for s in statuses) assert any("Инициализирую модель" in s for s in statuses)
assert any("Транскрибирую" in s for s in statuses) assert any("Транскрибирую" in s for s in statuses)
@@ -295,101 +295,6 @@ def test_transcribe_strict_cuda_error_during_transcription(mock_get_backend):
) )
# === load_model() tests ===
@patch("local_transcriber.transcriber.get_backend")
def test_load_model_cuda_fallback(mock_get_backend):
cuda_backend = _make_backend(create_model_error=RuntimeError("CUDA out of memory"))
cpu_model = MagicMock()
cpu_backend = _make_backend(model=cpu_model, model_path="/mock/cpu/model")
def backend_for_device(device, **kwargs):
return cuda_backend if device == "cuda" else cpu_backend
mock_get_backend.side_effect = backend_for_device
with pytest.warns(UserWarning, match="Переключение на CPU"):
model, actual_device, backend, model_path = load_model("tiny", "cuda", "int8")
assert actual_device == "cpu"
assert model is cpu_model
@patch("local_transcriber.transcriber.get_backend")
def test_load_model_strict_raises(mock_get_backend):
backend = _make_backend(create_model_error=RuntimeError("CUDA out of memory"))
mock_get_backend.return_value = backend
with pytest.raises(RuntimeError, match="CUDA out of memory"):
load_model("tiny", "cuda", "int8", strict_device=True)
@patch("local_transcriber.transcriber.get_backend")
def test_load_model_returns_backend_and_path(mock_get_backend):
backend = _make_backend(model_path="/mock/model/path")
mock_get_backend.return_value = backend
model, actual_device, returned_backend, model_path = load_model(
"tiny", "cpu", "int8"
)
assert returned_backend is backend
assert model_path == "/mock/model/path"
assert actual_device == "cpu"
@patch("local_transcriber.transcriber.get_backend")
def test_load_model_passes_cpu_threads_to_backend(mock_get_backend):
backend = _make_backend(model_path="/mock/model/path")
mock_get_backend.return_value = backend
load_model("tiny", "cpu", "int8", cpu_threads=8)
assert backend.create_model.call_args.kwargs["cpu_threads"] == 8
@patch("local_transcriber.transcriber.get_backend")
def test_load_model_fallback_preserves_cpu_threads(mock_get_backend):
cuda_backend = _make_backend(create_model_error=RuntimeError("CUDA out of memory"))
cpu_model = MagicMock()
cpu_backend = _make_backend(model=cpu_model, model_path="/mock/cpu/model")
def backend_for_device(device, **kwargs):
return cuda_backend if device == "cuda" else cpu_backend
mock_get_backend.side_effect = backend_for_device
with pytest.warns(UserWarning, match="Переключение на CPU"):
load_model("tiny", "cuda", "int8", cpu_threads=6)
assert cuda_backend.create_model.call_args.kwargs["cpu_threads"] == 6
assert cpu_backend.create_model.call_args.kwargs["cpu_threads"] == 6
# === _transcribe_file() tests ===
def test__transcribe_file_basic():
result_data = _make_result(count=2)
backend = _make_backend(transcribe_result=result_data)
tfr = _transcribe_file(
model=MagicMock(),
actual_device="cpu",
backend=backend,
model_path="/mock/model",
file_path=Path("test.mp3"),
model_name="tiny",
compute_type="int8",
)
assert len(tfr.result.segments) == 2
assert tfr.actual_device == "cpu"
assert tfr.backend is backend
assert tfr.model_path == "/mock/model"
# === ensure_model_available() tests (через FasterWhisperBackend) === # === ensure_model_available() tests (через FasterWhisperBackend) ===
@@ -492,202 +397,6 @@ def test_ensure_model_available_rejects_incomplete_local_directory(tmp_path):
ensure_model_available(str(model_dir)) ensure_model_available(str(model_dir))
# === Cross-backend fallback (openvino → cpu) ===
@patch("local_transcriber.transcriber.get_backend")
def test_load_model_openvino_gpu_fallback_to_cpu(mock_get_backend):
"""OpenVINO GPU ошибка при init → fallback на CPU (FasterWhisper)."""
ov_backend = _make_backend(
create_model_error=RuntimeError("OpenVINO model load failed"),
)
cpu_model = MagicMock()
cpu_backend = _make_backend(model=cpu_model, model_path="/mock/cpu/model")
def backend_for_device(device, **kwargs):
return ov_backend if device == "openvino-gpu" else cpu_backend
mock_get_backend.side_effect = backend_for_device
with pytest.warns(UserWarning, match="Переключение на CPU"):
model, actual_device, backend, model_path = load_model(
"medium",
"openvino-gpu",
"fp16",
)
assert actual_device == "cpu"
assert model is cpu_model
assert backend is cpu_backend
assert model_path == "/mock/cpu/model"
@patch("local_transcriber.transcriber.get_backend")
def test_load_model_openvino_cpu_fallback_to_cpu(mock_get_backend):
"""OpenVINO CPU ошибка при init → fallback на CPU (FasterWhisper)."""
ov_backend = _make_backend(
create_model_error=RuntimeError("OpenVINO model load failed"),
)
cpu_model = MagicMock()
cpu_backend = _make_backend(model=cpu_model, model_path="/mock/cpu/model")
def backend_for_device(device, **kwargs):
return ov_backend if device == "openvino-cpu" else cpu_backend
mock_get_backend.side_effect = backend_for_device
with pytest.warns(UserWarning, match="Переключение на CPU"):
model, actual_device, backend, model_path = load_model(
"medium",
"openvino-cpu",
"int8",
)
assert actual_device == "cpu"
assert model is cpu_model
assert backend is cpu_backend
assert model_path == "/mock/cpu/model"
@patch("local_transcriber.transcriber.get_backend")
def test_transcribe_file_openvino_gpu_midstream_fallback(mock_get_backend):
"""OpenVINO GPU ошибка при транскрипции → fallback на CPU."""
ov_backend = _make_backend(
transcribe_error=RuntimeError("OpenVINO inference error"),
)
cpu_backend = _make_backend(
transcribe_result=_make_result(count=2, device_used="cpu"),
model_path="/mock/cpu/model",
)
def backend_for_device(device, **kwargs):
return ov_backend if device.startswith("openvino") else cpu_backend
mock_get_backend.side_effect = backend_for_device
with pytest.warns(UserWarning, match="Переключение на CPU"):
tfr = _transcribe_file(
model=MagicMock(),
actual_device="openvino-gpu",
backend=ov_backend,
model_path="/mock/ov/model",
file_path=Path("test.mp3"),
model_name="medium",
compute_type="fp16",
)
assert tfr.actual_device == "cpu"
assert tfr.backend is cpu_backend
assert tfr.model_path == "/mock/cpu/model"
@patch("local_transcriber.transcriber.get_backend")
def test_transcribe_file_does_not_fallback_for_missing_word_timestamps(
mock_get_backend,
):
ov_backend = _make_backend(
transcribe_error=WordTimestampsUnavailableError("нет таймкодов"),
)
with pytest.raises(WordTimestampsUnavailableError, match="нет таймкодов"):
_transcribe_file(
model=MagicMock(),
actual_device="openvino-gpu",
backend=ov_backend,
model_path="/mock/ov/model",
file_path=Path("test.mp3"),
model_name="medium",
compute_type="fp16",
)
mock_get_backend.assert_not_called()
@patch("local_transcriber.transcriber.get_backend")
def test_transcribe_file_midstream_fallback_preserves_cpu_threads(mock_get_backend):
ov_backend = _make_backend(
transcribe_error=RuntimeError("OpenVINO inference error"),
)
cpu_backend = _make_backend(
transcribe_result=_make_result(count=2, device_used="cpu"),
model_path="/mock/cpu/model",
)
def backend_for_device(device, **kwargs):
return ov_backend if device.startswith("openvino") else cpu_backend
mock_get_backend.side_effect = backend_for_device
with pytest.warns(UserWarning, match="Переключение на CPU"):
_transcribe_file(
model=MagicMock(),
actual_device="openvino-gpu",
backend=ov_backend,
model_path="/mock/ov/model",
file_path=Path("test.mp3"),
model_name="medium",
compute_type="fp16",
cpu_threads=6,
)
assert cpu_backend.create_model.call_args.kwargs["cpu_threads"] == 6
@patch("local_transcriber.transcriber.get_backend")
def test_openvino_gpu_strict_device_no_fallback(mock_get_backend):
"""strict_device=True + OpenVINO GPU ошибка → raise."""
backend = _make_backend(
create_model_error=RuntimeError("OpenVINO model load failed"),
)
mock_get_backend.return_value = backend
with pytest.raises(RuntimeError, match="OpenVINO"):
load_model("medium", "openvino-gpu", "fp16", strict_device=True)
@patch("local_transcriber.transcriber.get_backend")
def test_openvino_runtime_error_triggers_fallback(mock_get_backend):
"""Любой RuntimeError от OpenVINO бэкенда → fallback."""
ov_backend = _make_backend(
create_model_error=RuntimeError("Exception from src/inference/..."),
)
cpu_backend = _make_backend(model_path="/mock/cpu/model")
def backend_for_device(device, **kwargs):
return ov_backend if device.startswith("openvino") else cpu_backend
mock_get_backend.side_effect = backend_for_device
with pytest.warns(UserWarning, match="Переключение на CPU"):
_, actual_device, _, _ = load_model("medium", "openvino-cpu", "int8")
assert actual_device == "cpu"
@patch("local_transcriber.transcriber.get_backend")
def test_load_model_resolves_openvino_gpu_device(mock_get_backend):
"""load_model обновляет actual_device по backend.actual_ov_device."""
backend = _make_backend()
backend.actual_ov_device = "GPU"
mock_get_backend.return_value = backend
_, actual_device, _, _ = load_model("medium", "openvino-cpu", "int8")
assert actual_device == "openvino-gpu"
@patch("local_transcriber.transcriber.get_backend")
def test_load_model_resolves_openvino_cpu_device(mock_get_backend):
"""load_model обновляет actual_device по backend.actual_ov_device."""
backend = _make_backend()
backend.actual_ov_device = "CPU"
mock_get_backend.return_value = backend
_, actual_device, _, _ = load_model("medium", "openvino-gpu", "fp16")
assert actual_device == "openvino-cpu"
def test_ensure_model_available_openvino_default_compute_type(): def test_ensure_model_available_openvino_default_compute_type():
"""ensure_model_available(device='openvino-cpu') без compute_type не падает.""" """ensure_model_available(device='openvino-cpu') без compute_type не падает."""
from local_transcriber.backends.openvino import OpenVINOBackend from local_transcriber.backends.openvino import OpenVINOBackend
@@ -697,3 +406,455 @@ 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.engine = "fake-engine"
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.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,
cpu_threads=3,
)
)
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.create_model.call_args.kwargs["cpu_threads"] == 3
assert run.execution.resolved_device == "cuda"
assert run.execution.device == "cpu"
@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", strict_device=False
)
)
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.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
@pytest.mark.parametrize(
("device", "ov_device", "gpu_name", "intel_name", "expected"),
[
("cpu", None, None, None, "CPU"),
("onnx", None, "RTX 3060", None, "ONNX (CPU)"),
("cuda", None, "RTX 3060", None, "CUDA (RTX 3060)"),
("cuda", None, None, None, "CUDA (Unknown GPU)"),
("openvino-gpu", "GPU", None, "Intel Arc 140T", "OpenVINO (Intel Arc 140T)"),
("openvino-gpu", "GPU", None, None, "OpenVINO (Intel GPU)"),
("openvino-cpu", "CPU", None, None, "OpenVINO (CPU)"),
],
)
@patch("local_transcriber.transcriber.get_backend")
def test_run_description_names_hardware_only_from_driver_data(
mock_get_backend, device, ov_device, gpu_name, intel_name, expected
):
"""Строка исполнения для шапки: ONNX не выдаётся за CPU faster-whisper, GPU — по драйверу."""
backend = _make_run_backend()
backend.actual_ov_device = ov_device
mock_get_backend.return_value = backend
with (
patch("local_transcriber.transcriber.get_gpu_name", return_value=gpu_name),
patch("local_transcriber.transcriber.get_intel_gpu_name", return_value=intel_name),
):
info = Transcriber(ExecutionRequest(device=device, model="m")).prepare()
assert info.description == expected
@patch("local_transcriber.transcriber.get_backend")
def test_run_openvino_runtime_error_falls_back_when_not_strict(mock_get_backend):
"""Любой RuntimeError OpenVINO считается ошибкой движка: не-strict запуск уходит на CPU."""
ov_backend = _make_run_backend(
create_model_error=RuntimeError("Exception from src/inference/src/core.cpp")
)
cpu_backend = _make_run_backend()
mock_get_backend.side_effect = lambda device, **_: (
ov_backend if device.startswith("openvino") else cpu_backend
)
run = Transcriber(
ExecutionRequest(device="openvino-gpu", model="medium", strict_device=False)
)
with pytest.warns(UserWarning, match="Переключение на CPU"):
result = run.transcribe(Path("a.mp3"))
assert result.device_used == "cpu"
@patch("local_transcriber.transcriber.get_backend")
def test_run_openvino_strict_raises_without_fallback(mock_get_backend):
mock_get_backend.return_value = _make_run_backend(
create_model_error=RuntimeError("GPU plugin failed")
)
with pytest.raises(RuntimeError, match="GPU plugin"):
Transcriber(ExecutionRequest(device="openvino-gpu", model="medium")).prepare()
assert mock_get_backend.call_count == 1
@patch("local_transcriber.transcriber.get_backend")
def test_run_reports_openvino_gpu_chosen_for_cpu_request(mock_get_backend):
"""Фактическое устройство OpenVINO важнее запрошенного и в обратную сторону."""
backend = _make_run_backend()
backend.actual_ov_device = "GPU"
mock_get_backend.return_value = backend
info = Transcriber(ExecutionRequest(device="openvino-cpu", model="medium")).prepare()
assert info.resolved_device == "openvino-cpu"
assert info.device == "openvino-gpu"
@patch("local_transcriber.transcriber.get_backend")
def test_run_fallback_rederives_implicit_compute_type_for_cpu(mock_get_backend):
"""Неявный compute_type следует за фактическим устройством: CPU не получает float16."""
cuda_backend = _make_run_backend(create_model_error=RuntimeError("CUDA error"))
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", strict_device=False))
with pytest.warns(UserWarning, match="Переключение на CPU"):
info = run.prepare()
cuda_backend.ensure_model_available.assert_called_once_with("medium", "float16", None)
cpu_backend.ensure_model_available.assert_called_once_with("medium", "float32", None)
assert info.compute_type == "float32"
@patch("local_transcriber.transcriber.get_backend")
def test_run_fallback_keeps_explicit_compute_type(mock_get_backend):
cuda_backend = _make_run_backend(create_model_error=RuntimeError("CUDA error"))
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", compute_type="int8", strict_device=False)
)
with pytest.warns(UserWarning):
run.prepare()
cpu_backend.ensure_model_available.assert_called_once_with("medium", "int8", None)
@patch("local_transcriber.transcriber.get_backend")
def test_run_failed_capability_check_does_not_mark_module_prepared(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.prepare()
with pytest.raises(ValueError, match="пословные таймкоды"):
run.transcribe(Path("a.mp3"))
backend.transcribe.assert_not_called()
@pytest.mark.parametrize(
("device", "model", "compute_type"),
[
("cpu", "medium", "float32"),
("cuda", "medium", "float16"),
("openvino-cpu", "medium", "int8"),
("openvino-gpu", "medium", "int8"),
("onnx", "gigaam-v3-e2e-rnnt", "int8"),
],
)
@patch("local_transcriber.transcriber.get_backend")
def test_run_applies_device_defaults_when_request_leaves_them_empty(
mock_get_backend, device, model, compute_type
):
backend = _make_run_backend()
mock_get_backend.return_value = backend
Transcriber(ExecutionRequest(device=device)).prepare()
mock_get_backend.assert_called_once_with(device, compute_type_explicit=False)
backend.ensure_model_available.assert_called_once_with(model, compute_type, None)
@patch("local_transcriber.transcriber.get_backend")
def test_run_explicit_values_override_device_defaults(mock_get_backend):
backend = _make_run_backend()
mock_get_backend.return_value = backend
Transcriber(ExecutionRequest(device="cuda", model="large-v3", compute_type="int8")).prepare()
mock_get_backend.assert_called_once_with("cuda", compute_type_explicit=True)
backend.ensure_model_available.assert_called_once_with("large-v3", "int8", None)
@patch("local_transcriber.transcriber.get_backend")
def test_run_engine_comes_from_adapter(mock_get_backend):
backend = _make_run_backend()
backend.engine = "openvino"
mock_get_backend.return_value = backend
info = Transcriber(ExecutionRequest(device="openvino-cpu", model="medium")).prepare()
assert info.engine == "openvino"
@patch("local_transcriber.transcriber.get_backend")
def test_run_diagnostics_failure_does_not_fail_the_run(mock_get_backend):
"""Сбой сбора runtime_info не роняет распознавание, а попадает в сведения."""
backend = _make_run_backend()
backend.runtime_info.side_effect = RuntimeError("plugin enumeration failed")
mock_get_backend.return_value = backend
info = Transcriber(ExecutionRequest(device="cpu", model="tiny")).prepare()
assert "plugin enumeration failed" in info.runtime["diagnostics"]