diff --git a/AGENTS.md b/AGENTS.md index bf76428..73dba05 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,11 +25,12 @@ Package manager is **uv** (not pip). Build backend is hatchling. ``` 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) → 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 - _transcribe_file() with mid-stream CUDA→CPU fallback + → transcriber.py Transcriber(ExecutionRequest): resolves auto + device defaults, + 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) ``` @@ -47,19 +48,21 @@ Three backends implement the `Backend` Protocol (structural typing, no inheritan ### 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). -- **Batch mode**: 3-phase pipeline (prescan → load model once → transcribe all). `TranscribeFileResult` carries updated model/backend/device state between files. -- **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`. +- **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`, 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. ## Testing 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` -- `_single_patches()` — helper assembling standard happy-path mock set -- `_make_result()` / `_make_tfr()` — factories for test data +- 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 +- `_cli_run()` — context manager assembling that standard set, yields `(backend, write_transcript)` +- `_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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 05fce13..b88f7d8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,25 +43,25 @@ tests/ CLI (cli.py) → config.py: загрузка .transcriber.toml, каскад дефолтов → utils.py: валидация файлов, определение устройства - → transcriber.py: загрузка модели (с CUDA fallback), транскрипция + → transcriber.py: Transcriber — разрешение устройства, загрузка модели один раз на запуск, транскрипция, fallback → formatter.py: сегменты → markdown с таймкодами → запись результата ``` ## Ключевые архитектурные решения -- **CUDA fallback** — двухуровневый: при загрузке модели и при транскрипции (mid-stream). GPU может быть видна через nvidia-smi, но не иметь достаточно VRAM. -- **Device-aware дефолты** — `compute_type` зависит от устройства (`float16`/`float32`). `float16` не работает на CPU, `float32` расточителен на GPU. +- **Module выполнения** (`Transcriber` в `transcriber.py`) — один владелец модели, adapter'а и фактического исполнения на запуск. Fallback GPU→CPU (при загрузке и mid-stream) живёт внутри и доступен только при `strict_device=False` из Python API: явный device в CLI strict, `auto` — ONNX CPU. +- **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` нельзя изменить в рантайме. -- **Батч-режим** — 3 фазы (prescan → load model → transcribe). Модель загружается один раз (~2-5 сек), невалидные файлы отсеиваются до загрузки. +- **Батч-режим** — 3 фазы (prescan → создание `Transcriber` → transcribe). Модель загружается один раз (~2-5 сек), невалидные файлы отсеиваются до загрузки; CLI не переносит состояние между файлами. - **Ручной glob в utils** — typer на Windows не раскрывает `*.mp4`, поэтому глобы обрабатываются явно. ## Тестирование -- Все CLI-тесты через `typer.testing.CliRunner` + моки (faster-whisper не вызывается) -- Моки: `load_config`, `validate_input_file`, `detect_device`, `ensure_model_available`, `load_model`, `_transcribe_file`, `write_transcript` -- Паттерн: `_single_patches()` — хелпер для стандартного happy-path набора моков -- `_make_result()` / `_make_tfr()` — фабрики тестовых данных +- Все CLI-тесты через `typer.testing.CliRunner` с настоящим `Transcriber` и fake adapter, подменённым в `local_transcriber.transcriber.get_backend` (библиотеки не вызываются) +- Моки: `load_config`, `validate_input_file`, `write_transcript` +- Паттерн: `_cli_run()` — контекстный менеджер стандартного набора, отдаёт `(backend, write_transcript)` +- `_make_result()` / `_make_backend()` — фабрики тестовых данных ## Частые задачи diff --git a/README.md b/README.md index f00e180..e989a23 100644 --- a/README.md +++ b/README.md @@ -302,11 +302,28 @@ transcribe --uninstall-menu | `--output` | `-o` | `<файл>-transcript.md` | Путь к выходному файлу | | `--device` | `-d` | `auto` | Устройство (auto, cpu, cuda, openvino, openvino-gpu, openvino-cpu, onnx) | | `--compute-type` | — | float16 (CUDA) / int8 (ONNX/OpenVINO) / float32 (CPU) | Тип вычислений | -| `--threads` | `-t` | 0 (авто) | Потоки CPU (рекомендуется = число физ. ядер) | +| `--threads` | `-t` | 0 (авто) | Потоки CPU (рекомендуется = число физ. ядер); для ONNX задаёт потоки и ASR, и VAD | | `--diarize` / `--no-diarize` | — | `false` | Включить или отключить разделение на реплики говорящих | | `--speakers` | — | авто | Ожидаемое число говорящих; включает диаризацию и несовместим с `--no-diarize` | | `--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. ## Платформы diff --git a/docs/adr/002-batch-and-config.md b/docs/adr/002-batch-and-config.md index 7dd8d7c..72b3936 100644 --- a/docs/adr/002-batch-and-config.md +++ b/docs/adr/002-batch-and-config.md @@ -2,6 +2,7 @@ **Статус**: Принято **Дата**: 2026-03-18 +**Обновлено**: 2026-09-21 — решения 2 и 4 пересмотрены (см. пометки) ## Контекст @@ -17,19 +18,23 @@ **Почему**: загрузка модели (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 -@dataclass -class TranscribeFileResult: - result: TranscribeResult - model: WhisperModel # может измениться при fallback - actual_device: str # может измениться при fallback -``` +Теперь состояние не покидает module выполнения: `Transcriber` создаётся один +раз на запуск после prescan, загружает модель при первом файле и сам держит +загруженную модель, adapter и фактическое исполнение. Fallback меняет +состояние внутри module, и следующий файл батча идёт уже по нему. Caller +получает `TranscribeResult` и `ExecutionInfo` (запрошенное и выбранное +исполнение, движок, модель, квантизация, потоки, версии runtime). -**Альтернатива**: передавать модель по ссылке через mutable контейнер — менее явно и сложнее тестировать. +**Альтернатива**: mutable-контейнер с моделью, передаваемый по ссылке, — +отклонена тогда же; owner-объект решает ту же задачу явно и тестируется +через свой interface. ### 3. Конфиг: CWD → глобальный, без мержа @@ -43,15 +48,18 @@ class TranscribeFileResult: **Приоритет значений**: CLI > конфиг > хардкод. -### 4. `_transcribe_file()` — внутренний helper +### 4. Публичный `transcribe()` — путь одного файла -Публичный API (`transcribe()`) сохранён без изменений. Новая функция `_transcribe_file()` — внутренний helper с префиксом `_`, не часть публичного контракта. - -`transcribe()` стала тонкой обёрткой: `load_model()` + `_transcribe_file()` → `TranscribeResult`. +**Пересмотрено 2026-09-21.** Внутренние helpers `load_model()` +и `_transcribe_file()` удалены. `transcribe()` сохранён и стал обёрткой над +тем же `Transcriber`: `model_name` и `compute_type` без значения берут +умолчания выбранного исполнения, `device="auto"` означает ONNX CPU, как в CLI. ## Последствия - Обратная совместимость CLI: `transcribe file.mp4` работает как раньше -- Все существующие тесты проходят без изменений сигнатур -- Batch mode: модель загружается один раз для всех файлов +- Batch mode: модель загружается один раз для всех файлов; CLI не переносит + состояние между файлами +- Fallback из CLI недостижим: явный device — strict, `auto` — ONNX CPU без + переходов. Механизм сохранён для `transcribe(..., strict_device=False)` - `--force` флаг для перезаписи существующих транскриптов в батч-режиме diff --git a/docs/adr/003-pluggable-backends.md b/docs/adr/003-pluggable-backends.md index be7f72e..0dd4587 100644 --- a/docs/adr/003-pluggable-backends.md +++ b/docs/adr/003-pluggable-backends.md @@ -44,21 +44,39 @@ CUDA bootstrap вызывается только при создании CUDA-м - `onnx` → OnnxAsrBackend - `auto` → ONNX на CPU независимо от `nvidia-smi` и установки extra -### load_model() — единственный владелец pipeline +### Transcriber — единственный владелец загрузки и исполнения -`load_model()` выполняет ensure_model_available + create_model в одном вызове. -CLI не вызывает ensure_model_available отдельно — это убирает двойной resolution -и гарантирует, что модель скачивается для правильного бэкенда. +Обновлено 2026-09-21. `Transcriber(ExecutionRequest)` в `transcriber.py` +разрешает `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 -Fallback живёт в `transcriber.py` (оркестратор), не в бэкендах: +Fallback живёт внутри `Transcriber`, не в бэкендах и не в CLI: - CUDA ошибка → CPU (FasterWhisper) - OpenVINO ошибка → CPU (FasterWhisper) -- `strict_device=True` (явный device из CLI или TOML) → ошибка без fallback +- strict запуск (явный device из CLI или TOML) → ошибка без fallback -При fallback в батч-режиме обновляются model, backend, model_path и actual_device -через TranscribeFileResult — следующий файл использует правильный бэкенд. +После перехода module сам держит новое состояние; следующий файл батча +использует его без участия caller. `WordTimestampsUnavailableError` +и ошибки пользовательских данных переходом не считаются. + +### Диагностика исполнения + +Каждый adapter отдаёт `runtime_info()` — версии runtime и фактическую +конфигурацию (для ONNX — доступные providers и те, что заданы сессиям ASR +и VAD, квантизация, бюджет потоков). `Transcriber` включает их в +`ExecutionInfo`; CLI печатает их в `--verbose`. Наличие provider в wheel не +считается доказательством выполнения графа на GPU/NPU. ### Аудио для 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` - Модели скачиваются по запросу — CUDA пользователь не качает OpenVINO модели, и наоборот - ARM и macOS: OpenVINO не ставится (platform markers), auto использует ONNX diff --git a/docs/specs/2026-09-21-transcription-execution.md b/docs/specs/2026-09-21-transcription-execution.md index cbf26af..10a081b 100644 --- a/docs/specs/2026-09-21-transcription-execution.md +++ b/docs/specs/2026-09-21-transcription-execution.md @@ -1,8 +1,8 @@ # Владение выполнением транскрипции -**Статус:** согласовано. Правила выбора исполнения и optional CUDA реализованы -в #2; рефакторинг владения выполнением не начат. Windows GPU-прогон без Toolkit -остаётся невыполненным (см. ADR-001). +**Статус:** реализовано. Правила выбора исполнения и optional CUDA — в #2, +владение выполнением (`Transcriber`), бюджет потоков ONNX и диагностика — +в #56. Windows GPU-прогон без Toolkit остаётся невыполненным (см. ADR-001). ## Зачем diff --git a/src/local_transcriber/backends/base.py b/src/local_transcriber/backends/base.py index 86036b8..9d46d30 100644 --- a/src/local_transcriber/backends/base.py +++ b/src/local_transcriber/backends/base.py @@ -16,6 +16,9 @@ class Backend(Protocol): наследование не требуется. """ + engine: str + """Имя движка для сведений о выполнении.""" + @property def word_timestamps_available(self) -> bool: """Гарантирует ли выбранный backend/model пословные таймкоды.""" @@ -53,3 +56,7 @@ class Backend(Protocol): ) -> TranscribeResult: """Транскрибирует файл, возвращает результат.""" ... + + def runtime_info(self) -> dict[str, str]: + """Версии runtime и фактическая конфигурация для удалённой диагностики.""" + ... diff --git a/src/local_transcriber/backends/faster_whisper.py b/src/local_transcriber/backends/faster_whisper.py index deddc81..d5767ee 100644 --- a/src/local_transcriber/backends/faster_whisper.py +++ b/src/local_transcriber/backends/faster_whisper.py @@ -16,6 +16,7 @@ from local_transcriber.types import ( Word, WordTimestampsUnavailableError, ) +from local_transcriber.utils import package_version MODEL_REPOS = { "tiny": "Systran/faster-whisper-tiny", @@ -43,10 +44,12 @@ MODEL_REQUIRED_FILES = [ class FasterWhisperBackend: """Бэкенд транскрипции через faster-whisper (CTranslate2).""" + engine = "faster-whisper" word_timestamps_available = True def __init__(self): self.actual_compute_type: str | None = None + self._cpu_threads = 0 def ensure_model_available( self, @@ -94,6 +97,7 @@ class FasterWhisperBackend: from faster_whisper import WhisperModel + self._cpu_threads = cpu_threads try: return WhisperModel( model_path, @@ -157,6 +161,22 @@ class FasterWhisperBackend: words=words, ) + def runtime_info(self) -> dict[str, str]: + """Версии CTranslate2/faster-whisper и число видимых CUDA-устройств.""" + import ctranslate2 + + try: + cuda_devices = str(ctranslate2.get_cuda_device_count()) + except Exception as exc: # noqa: BLE001 — диагностика не должна ронять запуск + cuda_devices = f"недоступно ({exc})" + return { + "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: if on_status is not None: diff --git a/src/local_transcriber/backends/onnx_asr.py b/src/local_transcriber/backends/onnx_asr.py index 5f4a668..0dc0e2f 100644 --- a/src/local_transcriber/backends/onnx_asr.py +++ b/src/local_transcriber/backends/onnx_asr.py @@ -15,6 +15,7 @@ from local_transcriber.types import ( Word, WordTimestampsUnavailableError, ) +from local_transcriber.utils import package_version @dataclass(frozen=True) @@ -131,6 +132,8 @@ def _normalize_quantization(compute_type: str) -> str | None: class OnnxAsrBackend: """Бэкенд транскрипции через onnx-asr (ONNX Runtime).""" + engine = "onnx-asr" + def __init__(self, compute_type_explicit: bool = True): self._compute_type_explicit = compute_type_explicit self.actual_compute_type: str | None = None @@ -138,6 +141,9 @@ class OnnxAsrBackend: self._model_name: str | None = None self._model_spec: OnnxModelSpec | None = None self._vad: Any = None + self._providers: list[str] = [] + self._quantization: str | None = None + self._cpu_threads = 0 @property def word_timestamps_available(self) -> bool: @@ -191,21 +197,49 @@ class OnnxAsrBackend: compute_type маппится в onnx-asr ``quantization`` — это суффикс файла модели; для unquantized (float32/fp32) нужно None, не строку. + cpu_threads > 0 задаёт intra_op_num_threads сессиям ASR и VAD; + 0 оставляет настройки потоков onnxruntime. """ import onnx_asr actual_compute_type = self.actual_compute_type or 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=model_path, 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._providers = providers + self._quantization = quantization + self._cpu_threads = cpu_threads 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( self, 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: values = [_compute_type_for_quantization(value) for value in quantizations] return ", ".join(sorted(values)) diff --git a/src/local_transcriber/backends/openvino.py b/src/local_transcriber/backends/openvino.py index e6ce106..1d359f4 100644 --- a/src/local_transcriber/backends/openvino.py +++ b/src/local_transcriber/backends/openvino.py @@ -19,6 +19,7 @@ from local_transcriber.types import ( Word, WordTimestampsUnavailableError, ) +from local_transcriber.utils import package_version # (model_alias, compute_type) → HF repo MODEL_REPOS: dict[tuple[str, str], str] = { @@ -56,6 +57,7 @@ MODEL_REQUIRED_FILES = [ class OpenVINOBackend: """Бэкенд транскрипции через openvino-genai WhisperPipeline.""" + engine = "openvino" word_timestamps_available = True def __init__( @@ -203,6 +205,18 @@ class OpenVINOBackend: 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]: """Находит HF repo для пары (model, compute_type) с fallback. diff --git a/src/local_transcriber/cli.py b/src/local_transcriber/cli.py index eed8f4d..2a56126 100644 --- a/src/local_transcriber/cli.py +++ b/src/local_transcriber/cli.py @@ -13,8 +13,6 @@ from rich.status import Status from .config import ( CliValues, - ResolvedConfig, - apply_device_defaults, load_config, resolve_defaults, ) @@ -39,11 +37,12 @@ from .quality import ( ) from .speaker_diarizer import SpeakerDiarizer, load_speaker_diarizer from .transcriber import ( + ExecutionInfo, + ExecutionRequest, Segment, + Transcriber, TranscribeResult, - _transcribe_file, cuda_error_hint, - load_model, ) from .types import ( UNKNOWN_LANGUAGE, @@ -53,10 +52,7 @@ from .types import ( ) from .utils import ( build_output_path, - detect_device, expand_globs, - get_gpu_name, - get_intel_gpu_name, has_existing_transcript, validate_input_file, ) @@ -95,21 +91,6 @@ def _with_cli_warning_renderer( 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: """Описывает источник языка, не выдавая профиль модели за детектор.""" if requested_language != "auto": @@ -348,7 +329,7 @@ def main( ) raise SystemExit(2) - resolved_device: str | None = None + requested_device: str | None = None try: config = load_config() cli_values: CliValues = { @@ -360,11 +341,17 @@ def main( } defaults = resolve_defaults(cli_values, config) diarize_enabled = defaults["diarize"] is True or speakers is not None + requested_device = defaults["device"] - resolved_device = detect_device(defaults["device"]) - defaults = apply_device_defaults(defaults, resolved_device, cli_values, config) - - ct_explicit = compute_type is not None or "compute_type" in config + # Умолчания model/compute_type зависят от устройства — их разрешает module. + request = ExecutionRequest( + device=requested_device, + model=defaults["model"], + compute_type=defaults["compute_type"], + language=defaults["language"], + cpu_threads=threads, + require_word_timestamps=diarize_enabled, + ) expanded = expand_globs(files) if not expanded: @@ -381,22 +368,18 @@ def main( if is_batch: _run_batch( expanded, - defaults, + request, verbose, force, - ct_explicit, - cpu_threads=threads, diarize=diarize_enabled, speakers=speakers, ) else: _run_single( expanded[0], - defaults, + request, output, verbose, - ct_explicit, - cpu_threads=threads, diarize=diarize_enabled, speakers=speakers, ) @@ -406,15 +389,15 @@ def main( except SystemExit: raise except ValueError as exc: - _print_cuda_hint(exc, resolved_device) + _print_cuda_hint(exc, requested_device) console.print(f"Ошибка: {exc}", style="red bold") raise SystemExit(1) except (FileNotFoundError,) as exc: - _print_cuda_hint(exc, resolved_device) + _print_cuda_hint(exc, requested_device) console.print(f"Ошибка: {exc}", style="red bold") raise SystemExit(1) except Exception as exc: - _print_cuda_hint(exc, resolved_device) + _print_cuda_hint(exc, requested_device) if verbose: console.print_exception() else: @@ -423,13 +406,50 @@ def main( 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( file: Path, - defaults: ResolvedConfig, + request: ExecutionRequest, output: Path | None, verbose: bool, - compute_type_explicit: bool = False, - cpu_threads: int = 0, diarize: bool = False, speakers: int | None = None, ) -> None: @@ -437,9 +457,6 @@ def _run_single( start = time.monotonic() 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) console.print(f"Файл: [bold]{validated_file.name}[/bold]") @@ -447,59 +464,18 @@ def _run_single( def on_segment(seg: Segment) -> None: console.print(f" [{seg.start:.2f}s] {seg.text.strip()}") - model_obj, actual_device, backend, model_path = load_model( - 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, - ) - 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), - ) + transcriber = Transcriber(request) + info = transcriber.prepare(on_status=lambda msg: console.print(msg)) + _print_execution_header(info, verbose) + speaker_diarizer = _load_diarizer(diarize, speakers, request.cpu_threads) with Status("Подготавливаю запуск...", console=console) as status: - tfr = _transcribe_file( - model=model_obj, - 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, + result = transcriber.transcribe( + validated_file, on_segment=on_segment if verbose else None, on_status=status.update, - strict_device=strict, - cpu_threads=cpu_threads, ) - result = tfr.result speaker_transcript = None diarization_warning = None diarization_degraded = False @@ -526,33 +502,19 @@ def _run_single( if diarization_warning is not None: 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: message = f"Речь не обнаружена в файле {validated_file.name}" if speaker_diarizer is not None: message += "; диаризация не запускалась" console.print(message, style="yellow") - device_info = _format_device_info(result.device_used) - language_mode = _format_language_mode(defaults["language"], result) + language_mode = _format_language_mode(request.language or "auto", result) content = format_transcript( result=result, source_filename=validated_file.name, - model_name=defaults["model"], - device_info=device_info, + model_name=info.model, + device_info=info.description, language_mode=language_mode, speaker_transcript=speaker_transcript, diarization_warning=diarization_warning, @@ -569,11 +531,9 @@ def _run_single( def _run_batch( files: list[Path], - defaults: ResolvedConfig, + request: ExecutionRequest, verbose: bool, force: bool, - compute_type_explicit: bool = False, - cpu_threads: int = 0, diarize: bool = False, speakers: int | None = None, ) -> None: @@ -603,50 +563,11 @@ def _run_batch( raise SystemExit(1) return - # Phase 2: Load model (ensure + create в одном вызове) - requested_device = defaults["device"] - resolved_device = detect_device(requested_device) - strict = requested_device != "auto" - model_obj, actual_device, backend, model_path = load_model( - 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 2: module выполнения создаётся, когда есть работа + transcriber = Transcriber(request) + info = transcriber.prepare(on_status=lambda msg: console.print(msg)) + _print_execution_header(info, verbose) + speaker_diarizer = _load_diarizer(diarize, speakers, request.cpu_threads) # Phase 3: Transcribe processed = 0 @@ -664,38 +585,15 @@ def _run_batch( console.print(f" [{seg.start:.2f}s] {seg.text.strip()}") with Status(f"{prefix}...", console=console) as status: - tfr = _transcribe_file( - model=model_obj, - 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, + result = transcriber.transcribe( + file, on_segment=on_segment if verbose else None, on_status=status.update if not verbose else lambda msg: console.print(msg), - strict_device=strict, - cpu_threads=cpu_threads, ) - if tfr.actual_device != actual_device: - 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) + language_mode = _format_language_mode(request.language or "auto", result) speaker_transcript = None diarization_warning = None file_degraded = False @@ -735,13 +633,11 @@ def _run_batch( message += "; диаризация не запускалась" console.print(message, style="yellow") - device_info = _format_device_info(result.device_used) - content = format_transcript( result=result, source_filename=file.name, - model_name=defaults["model"], - device_info=device_info, + model_name=info.model, + device_info=info.description, language_mode=language_mode, speaker_transcript=speaker_transcript, diarization_warning=diarization_warning, @@ -760,7 +656,7 @@ def _run_batch( except KeyboardInterrupt: raise except Exception as exc: - _print_cuda_hint(exc, actual_device) + _print_cuda_hint(exc, request.device) if verbose: console.print_exception() else: diff --git a/src/local_transcriber/config.py b/src/local_transcriber/config.py index 0f36361..c27cd75 100644 --- a/src/local_transcriber/config.py +++ b/src/local_transcriber/config.py @@ -30,12 +30,16 @@ class CliValues(TypedDict, total=False): class ResolvedConfig(TypedDict): - """Полная конфигурация после разрешения каскада.""" + """Конфигурация после каскада CLI > TOML. - model: str + ``model`` и ``compute_type`` остаются ``None``, если не заданы явно: + их умолчания зависят от устройства и разрешаются при загрузке модели. + """ + + model: str | None language: str device: str - compute_type: str + compute_type: str | None diarize: bool @@ -47,6 +51,8 @@ HARDCODED_DEFAULTS: ResolvedConfig = { "diarize": False, } +# Умолчания по устройству; HARDCODED_DEFAULTS — последний резерв для +# устройств вне таблицы. DEVICE_DEFAULTS: dict[str, dict[str, str]] = { "cuda": {"model": "medium", "compute_type": "float16"}, "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: - """Каскад приоритетов: CLI > конфиг-файл > hardcoded-дефолты.""" + """Каскад приоритетов: CLI > конфиг-файл > hardcoded-дефолты. + + Device-aware ключи (model, compute_type) без явного значения остаются None. + """ return { - "model": _resolve_value( - cli_values.get("model"), config.get("model"), HARDCODED_DEFAULTS["model"] - ), + "model": _resolve_value(cli_values.get("model"), config.get("model"), None), "language": _resolve_value( cli_values.get("language"), config.get("language"), @@ -152,9 +159,7 @@ def resolve_defaults(cli_values: CliValues, config: ConfigValues) -> ResolvedCon HARDCODED_DEFAULTS["device"], ), "compute_type": _resolve_value( - cli_values.get("compute_type"), - config.get("compute_type"), - HARDCODED_DEFAULTS["compute_type"], + cli_values.get("compute_type"), config.get("compute_type"), None ), "diarize": _resolve_value( 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: return config_value 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) diff --git a/src/local_transcriber/transcriber.py b/src/local_transcriber/transcriber.py index a516485..3313b80 100644 --- a/src/local_transcriber/transcriber.py +++ b/src/local_transcriber/transcriber.py @@ -2,170 +2,245 @@ import warnings from collections.abc import Callable +from dataclasses import dataclass, field from pathlib import Path from typing import Any from local_transcriber.backends import get_backend +from local_transcriber.config import DEVICE_DEFAULTS, HARDCODED_DEFAULTS # Re-export из types.py для обратной совместимости -from local_transcriber.types import ( # noqa: F401 +from local_transcriber.types import ( Segment, - TranscribeFileResult, TranscribeResult, WordTimestampsUnavailableError, ) +from local_transcriber.utils import detect_device, get_gpu_name, get_intel_gpu_name -def load_model( - model_name: str, - 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. +@dataclass(frozen=True) +class ExecutionRequest: + """Запрошенные настройки запуска до разрешения умолчаний. - Возвращает (model, actual_device, backend, model_path). - compute_type_explicit: True если пользователь явно указал --compute-type. - cpu_threads: число потоков для CPU inference (0 = дефолт библиотеки). + ``None`` в model/compute_type означает device-aware умолчание; + ``strict_device=None`` — strict для любого явного device, кроме ``auto``. """ - 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}...") - model = backend.create_model( - model_path, device, compute_type, cpu_threads=cpu_threads + +@dataclass(frozen=True) +class ExecutionInfo: + """Сведения о выполнении: что запрошено, что выбрано и чем выполняется.""" + + requested_device: str + resolved_device: str # выбор до загрузки; отличается от device после fallback + device: str + engine: str + model: str + compute_type: str + cpu_threads: int + word_timestamps_available: bool + description: str # строка исполнения для шапки транскрипта + runtime: dict[str, str] = field(default_factory=dict) + + +class Transcriber: + """Module выполнения распознавания на один запуск. + + Владеет загруженной моделью, adapter'ом, фактическими настройками + и разрешёнными переходами исполнения. Caller передаёт файлы по одному + и получает результат; состояние между файлами не переносит. + """ + + def __init__(self, request: ExecutionRequest) -> None: + self._request = request + self._strict = ( + request.strict_device + if request.strict_device is not None + else request.device != "auto" ) - # Резолвим actual_device по реальному OpenVINO device - ov_dev = getattr(backend, "actual_ov_device", None) - if ov_dev == "GPU" and actual_device != "openvino-gpu": - actual_device = "openvino-gpu" - elif ( - ov_dev == "CPU" - and actual_device.startswith("openvino") - and actual_device != "openvino-cpu" - ): - actual_device = "openvino-cpu" - except (RuntimeError, ValueError) as exc: - if device != "cpu" and _is_backend_error(exc, device): - if strict_device: + self._compute_type_explicit = request.compute_type is not None + self._resolved_device = detect_device(request.device) + self._device = self._resolved_device + self._backend: Any = None + self._model: Any = None + self._info: ExecutionInfo | None = None + + @property + def execution(self) -> ExecutionInfo: + """Текущие сведения о выполнении; доступны после prepare().""" + if self._info is None: + raise RuntimeError("Модель ещё не подготовлена: вызовите prepare()") + return self._info + + def prepare(self, on_status: Callable[[str], None] | None = None) -> ExecutionInfo: + """Загружает модель и проверяет возможности до первого файла. Идемпотентно.""" + if self._info is not None: + return self._info + try: + info = self._load(self._device, on_status) + except (RuntimeError, ValueError) as exc: + if not self._may_fall_back(exc): raise warnings.warn( - f"Не удалось загрузить модель на {device}: {exc}. Переключение на CPU.", + f"Не удалось загрузить модель на {self._device}: {exc}. " + "Переключение на CPU.", stacklevel=2, ) - actual_device = "cpu" - backend = get_backend("cpu") - model_path = backend.ensure_model_available( - model_name, compute_type, on_status + info = self._load("cpu", on_status) + if self._request.require_word_timestamps and not info.word_timestamps_available: + raise ValueError( + "Выбранный движок или модель не поддерживает пословные таймкоды" ) - _notify_status(on_status, "Инициализирую модель на cpu...") - model = backend.create_model( - model_path, "cpu", compute_type, cpu_threads=cpu_threads - ) - else: - raise + self._info = info + return info - return model, actual_device, backend, model_path - - -def _transcribe_file( - model: Any, - actual_device: str, - backend: Any, - model_path: str, - file_path: Path, - model_name: str, - compute_type: str, - language: str | None = None, - on_segment: Callable[[Segment], None] | None = None, - on_status: Callable[[str], None] | None = None, - strict_device: bool = False, - cpu_threads: int = 0, -) -> 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: + def transcribe( + self, + file_path: Path, + on_segment: Callable[[Segment], None] | None = None, + on_status: Callable[[str], None] | None = None, + ) -> TranscribeResult: + """Транскрибирует один файл; при разрешённом fallback перезагружает модель.""" + self.prepare(on_status) + lang_arg = self._request.language or None + if lang_arg == "auto": + lang_arg = None + try: + result = self._recognize(file_path, lang_arg, on_segment, on_status) + except (RuntimeError, ValueError) as exc: + if not self._may_fall_back(exc): raise warnings.warn( - f"Ошибка при транскрипции на {actual_device}: {exc}. " + f"Ошибка при транскрипции на {self._device}: {exc}. " "Переключение на CPU и повтор.", stacklevel=2, ) - actual_device = "cpu" - backend = get_backend("cpu") - model_path = backend.ensure_model_available( - 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 + self._info = self._load("cpu", on_status) + result = self._recognize(file_path, lang_arg, on_segment, on_status) + return result - return TranscribeFileResult( - result=result, - model=model, - actual_device=actual_device, - backend=backend, - model_path=model_path, - ) + def _recognize( + self, + file_path: Path, + language: str | None, + on_segment: Callable[[Segment], None] | None, + on_status: Callable[[str], None] | None, + ) -> TranscribeResult: + _notify_status(on_status, "Транскрибирую...") + result = self._backend.transcribe( + self._model, file_path, language, on_segment, on_status + ) + result.device_used = self._device + return result + + def _may_fall_back(self, exc: BaseException) -> bool: + """Разрешённый переход: не strict, не CPU и ошибка самого движка.""" + if self._strict or self._device == "cpu": + return False + return _is_backend_error(exc, self._device) + + def _load( + self, device: str, on_status: Callable[[str], None] | None + ) -> 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( file_path: Path, - model_name: str = "large-v3", + model_name: str | None = None, device: str = "auto", - compute_type: str = "int8", + compute_type: str | None = None, language: str | None = None, on_segment: Callable[[Segment], None] | None = None, on_status: Callable[[str], None] | None = None, strict_device: bool = False, cpu_threads: int = 0, ) -> TranscribeResult: - """High-level API: загрузка модели + транскрипция за один вызов.""" - model, actual_device, backend, model_path = load_model( - model_name, - device, - compute_type, - on_status, - strict_device, - compute_type_explicit=True, # Python API — caller explicitly chose compute_type - cpu_threads=cpu_threads, + """Публичный путь одного файла через тот же module выполнения, что и CLI. + + ``None`` в model_name/compute_type берёт умолчания выбранного исполнения. + """ + run = Transcriber( + ExecutionRequest( + device=device, + model=model_name, + compute_type=compute_type, + language=language, + cpu_threads=cpu_threads, + strict_device=strict_device, + ) ) - tfr = _transcribe_file( - model, - actual_device, - backend, - model_path, - file_path, - model_name, - compute_type, - language, - on_segment, - on_status, - strict_device, - cpu_threads=cpu_threads, - ) - return tfr.result + return run.transcribe(file_path, on_segment, on_status) def ensure_model_available( @@ -175,8 +250,6 @@ def ensure_model_available( on_status: Callable[[str], None] | None = None, ) -> str: """Публичный helper: гарантирует наличие модели для указанного бэкенда.""" - from local_transcriber.config import DEVICE_DEFAULTS, HARDCODED_DEFAULTS - if compute_type is None: device_defs = DEVICE_DEFAULTS.get(device, {}) compute_type = device_defs.get( diff --git a/src/local_transcriber/types.py b/src/local_transcriber/types.py index d8b49c4..dd96461 100644 --- a/src/local_transcriber/types.py +++ b/src/local_transcriber/types.py @@ -2,7 +2,6 @@ from collections.abc import Callable from dataclasses import dataclass, field -from typing import Any # Единый признак «язык неизвестен» для всех бэкендов UNKNOWN_LANGUAGE = "unknown" @@ -83,14 +82,5 @@ class TranscribeResult: 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 SegmentCallback = Callable[[Segment], None] | None diff --git a/src/local_transcriber/utils.py b/src/local_transcriber/utils.py index 84e8a22..3996e87 100644 --- a/src/local_transcriber/utils.py +++ b/src/local_transcriber/utils.py @@ -4,6 +4,7 @@ import glob import platform import subprocess import warnings +from importlib.metadata import PackageNotFoundError, version from pathlib import Path SUPPORTED_EXTENSIONS = { @@ -141,3 +142,11 @@ def expand_globs(paths: list[Path]) -> list[Path]: def has_existing_transcript(input_path: Path) -> bool: """Проверяет наличие транскрипта для skip-логики батч-режима.""" return build_output_path(input_path).exists() + + +def package_version(name: str) -> str: + """Версия установленного пакета для диагностики; отсутствие не считается ошибкой.""" + try: + return version(name) + except PackageNotFoundError: + return "не установлен" diff --git a/tests/test_backend_faster_whisper.py b/tests/test_backend_faster_whisper.py index f889c0e..4cf4423 100644 --- a/tests/test_backend_faster_whisper.py +++ b/tests/test_backend_faster_whisper.py @@ -83,3 +83,18 @@ def test_transcribe_rejects_one_nonempty_segment_without_word_timestamps(tmp_pat with pytest.raises(RuntimeError, match="пословные таймкоды"): FasterWhisperBackend().transcribe(model, tmp_path / "audio.wav", "ru") + + +def test_runtime_info_reports_versions_and_cuda_devices(monkeypatch): + """Диагностика называет версии runtime и число CUDA-устройств, не обещая ускорения.""" + monkeypatch.setattr("ctranslate2.get_cuda_device_count", lambda: 0) + backend = FasterWhisperBackend() + backend.actual_compute_type = "float32" + + info = backend.runtime_info() + + assert info["ctranslate2"] + assert info["faster_whisper"] + assert info["cuda_devices"] == "0" + assert info["compute_type"] == "float32" + assert info["cpu_threads"] == "по умолчанию" diff --git a/tests/test_backend_openvino.py b/tests/test_backend_openvino.py index 8d44dbb..97ed20a 100644 --- a/tests/test_backend_openvino.py +++ b/tests/test_backend_openvino.py @@ -463,3 +463,21 @@ def test_validate_model_dir_requires_alignment_heads_for_word_timestamps(tmp_pat with pytest.raises(ValueError, match="alignment_heads"): _validate_model_dir(tmp_path) + + +def test_runtime_info_reports_available_and_actual_devices(monkeypatch): + """Диагностика показывает устройства OpenVINO и то, что выбрано фактически.""" + core = MagicMock() + core.available_devices = ["CPU", "GPU"] + monkeypatch.setattr("openvino.Core", lambda: core) + backend = OpenVINOBackend(ov_device="openvino-gpu", compute_type_explicit=True) + backend.actual_ov_device = "GPU" + backend.actual_compute_type = "fp16" + + info = backend.runtime_info() + + assert info["openvino"] + assert info["available_devices"] == "CPU, GPU" + assert info["device"] == "GPU" + assert info["compute_type"] == "fp16" + assert "--threads не применяется" in info["threads"] diff --git a/tests/test_cli.py b/tests/test_cli.py index 72627d8..b9fcfb4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,23 +1,20 @@ import warnings +from contextlib import ExitStack, contextmanager from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import ANY, MagicMock, patch import pytest from rich.console import Console from typer.testing import CliRunner -from local_transcriber.cli import _format_device_info, _format_language_mode, app +from local_transcriber.cli import _format_language_mode, app from local_transcriber.formatter import ( LANGUAGE_DETECTED, LANGUAGE_FORCED, LANGUAGE_FROM_MODEL, LANGUAGE_UNKNOWN, ) -from local_transcriber.transcriber import ( - Segment, - TranscribeFileResult, - TranscribeResult, -) +from local_transcriber.transcriber import Segment, TranscribeResult from local_transcriber.types import ( UNKNOWN_LANGUAGE, DiarizationRun, @@ -40,34 +37,54 @@ def _make_result(segments=None, language="ru", device_used="cpu", duration=60.0) ) -def _make_model(): - return MagicMock(name="WhisperModel") - - -def _make_backend(): - return MagicMock(name="Backend") - - -def _make_tfr( +def _make_backend( result=None, - model=None, - actual_device="cpu", - backend=None, - model_path="/models/medium", + word_timestamps=True, + actual_ov_device=None, + runtime=None, ): - if result is None: - result = _make_result() - if model is None: - model = _make_model() - if backend is None: - backend = _make_backend() - return TranscribeFileResult( - result=result, - model=model, - actual_device=actual_device, - backend=backend, - model_path=model_path, + """Fake adapter на internal seam Backend; module выполнения работает по-настоящему.""" + backend = MagicMock(name="Backend") + backend.engine = "onnx-asr" + backend.word_timestamps_available = word_timestamps + backend.actual_compute_type = None + backend.actual_ov_device = actual_ov_device + backend.ensure_model_available.side_effect = ( + lambda model_name, compute_type, on_status=None: f"/models/{model_name}" ) + backend.transcribe.return_value = result if result is not None else _make_result() + backend.runtime_info.return_value = runtime or {} + return backend + + +@contextmanager +def _cli_run(result=None, tmp_file=None, backend=None, config=None, write=True): + """Стандартный CLI-прогон: конфиг, валидация файла, fake adapter, запись. + + Отдаёт (backend, write_transcript); ``tmp_file=None`` пропускает файлы + как есть, ``write=False`` оставляет настоящую запись транскрипта. + """ + if backend is None: + backend = _make_backend(result) + validate = ( + {"return_value": tmp_file} + if tmp_file is not None + else {"side_effect": lambda p: p} + ) + with ExitStack() as stack: + stack.enter_context( + patch("local_transcriber.cli.load_config", return_value=config or {}) + ) + stack.enter_context(patch("local_transcriber.cli.validate_input_file", **validate)) + stack.enter_context( + patch("local_transcriber.transcriber.get_backend", return_value=backend) + ) + write_transcript = ( + stack.enter_context(patch("local_transcriber.cli.write_transcript")) + if write + else None + ) + yield backend, write_transcript @pytest.mark.parametrize( @@ -86,37 +103,44 @@ def test_format_language_mode(requested_language, language, probability, expecte assert _format_language_mode(requested_language, result) == expected -def _single_patches(result=None, tmp_file=None, actual_device="cpu"): - """Patches for a standard single-file CLI happy path.""" - if result is None: - result = _make_result(device_used=actual_device) - model = _make_model() - backend = _make_backend() - tfr = _make_tfr( - result=result, model=model, actual_device=actual_device, backend=backend - ) - return [ - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", return_value=tmp_file), - patch("local_transcriber.cli.detect_device", return_value=actual_device), - patch( - "local_transcriber.cli.load_model", - return_value=(model, actual_device, backend, "/models/medium"), - ), - patch("local_transcriber.cli._transcribe_file", return_value=tfr), - patch("local_transcriber.cli.write_transcript"), - ] - - def test_cli_happy_path_exit_code_zero(tmp_path): audio = tmp_path / "test.mp3" audio.write_bytes(b"fake") - patches = _single_patches(tmp_file=audio) - with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5]: + with _cli_run(tmp_file=audio) as (backend, _write): out = runner.invoke(app, [str(audio)]) assert out.exit_code == 0 + backend.ensure_model_available.assert_called_once_with( + "gigaam-v3-e2e-rnnt", "int8", ANY + ) + assert "Модель: gigaam-v3-e2e-rnnt Устройство: onnx Compute: int8" in out.output + + +def test_cli_verbose_prints_runtime_diagnostics(tmp_path): + """--verbose показывает движок, потоки и версии runtime из данных module.""" + audio = tmp_path / "test.mp3" + audio.write_bytes(b"fake") + backend = _make_backend(runtime={"onnxruntime": "1.28.0", "asr_providers": "CPUExecutionProvider"}) + + with _cli_run(tmp_file=audio, backend=backend): + out = runner.invoke(app, [str(audio), "--verbose", "--threads", "4"]) + + assert out.exit_code == 0 + assert "Движок: onnx-asr Потоки (запрошено): 4" in out.output + assert "onnxruntime: 1.28.0" in out.output + assert "asr_providers: CPUExecutionProvider" in out.output + + +def test_cli_without_verbose_hides_runtime_diagnostics(tmp_path): + audio = tmp_path / "test.mp3" + audio.write_bytes(b"fake") + backend = _make_backend(runtime={"onnxruntime": "1.28.0"}) + + with _cli_run(tmp_file=audio, backend=backend): + out = runner.invoke(app, [str(audio)]) + + assert "onnxruntime" not in out.output @pytest.mark.parametrize("file_count", [1, 2]) @@ -125,34 +149,20 @@ def test_cli_renders_runtime_warning_without_python_details(tmp_path, file_count for file in files: file.write_bytes(b"fake") - result = _make_result() - model = _make_model() - backend = _make_backend() - tfr = _make_tfr(result=result, model=model, backend=backend) warning_message = ( "Тестовое [bold]предупреждение[/bold] с длинным текстом, который " "должен остаться одной логической строкой без служебных подробностей Python" ) - def transcribe_with_warning(**_kwargs): + def transcribe_with_warning(*_args, **_kwargs): warnings.warn(warning_message, stacklevel=2) - return tfr + return _make_result() + + backend = _make_backend() + backend.transcribe.side_effect = transcribe_with_warning original_showwarning = warnings.showwarning - with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cpu", backend, "/models/medium"), - ), - patch( - "local_transcriber.cli._transcribe_file", - side_effect=transcribe_with_warning, - ), - patch("local_transcriber.cli.write_transcript"), - ): + with _cli_run(backend=backend): out = runner.invoke(app, [str(file) for file in files]) warning_lines = [line for line in out.output.splitlines() if "Тестовое" in line] @@ -165,30 +175,15 @@ def test_cli_renders_runtime_warning_without_python_details(tmp_path, file_count def test_cli_default_options_passed_to_transcribe(tmp_path): audio = tmp_path / "test.mp3" audio.write_bytes(b"fake") - result = _make_result(device_used="onnx") - model = _make_model() - backend = _make_backend() - tfr = _make_tfr(result=result, model=model, actual_device="onnx", backend=backend) - mock_transcribe_file = MagicMock(return_value=tfr) - with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", return_value=audio), - patch("local_transcriber.cli.detect_device", return_value="onnx"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "onnx", backend, "/models/gigaam-v3-e2e-rnnt"), - ), - patch("local_transcriber.cli._transcribe_file", mock_transcribe_file), - patch("local_transcriber.cli.write_transcript"), - ): + with _cli_run(tmp_file=audio) as (backend, _write): out = runner.invoke(app, [str(audio)]) - call_kwargs = mock_transcribe_file.call_args[1] - assert call_kwargs["model_name"] == "gigaam-v3-e2e-rnnt" - assert call_kwargs["compute_type"] == "int8" - assert call_kwargs["language"] == "ru" - assert call_kwargs["on_segment"] is None # verbose=False + backend.ensure_model_available.assert_called_once_with( + "gigaam-v3-e2e-rnnt", "int8", ANY + ) + assert backend.transcribe.call_args[0][2] == "ru" + assert backend.transcribe.call_args[0][3] is None # verbose=False → on_segment assert "Модель: gigaam-v3-e2e-rnnt" in out.output assert "Устройство: onnx" in out.output @@ -196,20 +191,8 @@ def test_cli_default_options_passed_to_transcribe(tmp_path): def test_cli_identifies_onnx_backend_in_transcript_header(tmp_path): audio = tmp_path / "test.mp3" audio.write_bytes(b"fake") - result = _make_result(device_used="onnx") - model = _make_model() - backend = _make_backend() - tfr = _make_tfr(result=result, model=model, actual_device="onnx", backend=backend) - with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.detect_device", return_value="onnx"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "onnx", backend, "/models/gigaam-v3-e2e-rnnt"), - ), - patch("local_transcriber.cli._transcribe_file", return_value=tfr), - ): + with _cli_run(tmp_file=audio, write=False): out = runner.invoke(app, [str(audio)]) content = (tmp_path / "test-transcript.md").read_text(encoding="utf-8") @@ -220,24 +203,8 @@ def test_cli_identifies_onnx_backend_in_transcript_header(tmp_path): def test_cli_custom_options(tmp_path): audio = tmp_path / "test.mp3" audio.write_bytes(b"fake") - result = _make_result(device_used="cuda") - model = _make_model() - backend = _make_backend() - tfr = _make_tfr(result=result, model=model, actual_device="cuda", backend=backend) - mock_transcribe_file = MagicMock(return_value=tfr) - with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", return_value=audio), - patch("local_transcriber.cli.detect_device", return_value="cuda"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cuda", backend, "/models/small"), - ), - patch("local_transcriber.cli._transcribe_file", mock_transcribe_file), - patch("local_transcriber.cli.write_transcript"), - patch("local_transcriber.cli.get_gpu_name", return_value="RTX 3060"), - ): + with _cli_run(tmp_file=audio) as (backend, _write): runner.invoke( app, [ @@ -253,10 +220,8 @@ def test_cli_custom_options(tmp_path): ], ) - call_kwargs = mock_transcribe_file.call_args[1] - assert call_kwargs["model_name"] == "small" - assert call_kwargs["language"] == "ru" - assert call_kwargs["compute_type"] == "float16" + backend.ensure_model_available.assert_called_once_with("small", "float16", ANY) + assert backend.transcribe.call_args[0][2] == "ru" def test_cli_speakers_enables_diarization_and_writes_speaker_markdown(tmp_path): @@ -271,10 +236,6 @@ def test_cli_speakers_enables_diarization_and_writes_speaker_markdown(tmp_path): Word(0.5, 1.0, "Второй."), Word(1.1, 1.3, "Неясно."), ] - model = _make_model() - backend = _make_backend() - backend.word_timestamps_available = True - tfr = _make_tfr(result=result, model=model, backend=backend) diarizer = MagicMock() diarizer.process.return_value = DiarizationRun( intervals=[ @@ -283,22 +244,13 @@ def test_cli_speakers_enables_diarization_and_writes_speaker_markdown(tmp_path): ], elapsed_seconds=0.2, ) - write = MagicMock() with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", return_value=audio), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cpu", backend, "/models/medium"), - ), - patch("local_transcriber.cli._transcribe_file", return_value=tfr), + _cli_run(tmp_file=audio, result=result) as (_backend, write), patch( "local_transcriber.cli.load_speaker_diarizer", return_value=diarizer, ) as load_diarizer, - patch("local_transcriber.cli.write_transcript", write), ): out = runner.invoke( app, @@ -325,28 +277,15 @@ def test_cli_diarization_error_writes_plain_transcript_and_exits_nonzero(tmp_pat duration=10.0, ) result.words = [Word(0.0, 1.0, "Полезный текст.")] - model = _make_model() - backend = _make_backend() - backend.word_timestamps_available = True - tfr = _make_tfr(result=result, model=model, backend=backend) diarizer = MagicMock() diarizer.process.side_effect = RuntimeError("boom") - write = MagicMock() with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", return_value=audio), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cpu", backend, "/models/medium"), - ), - patch("local_transcriber.cli._transcribe_file", return_value=tfr), + _cli_run(tmp_file=audio, result=result) as (_backend, write), patch( "local_transcriber.cli.load_speaker_diarizer", return_value=diarizer, ), - patch("local_transcriber.cli.write_transcript", write), ): out = runner.invoke(app, [str(audio), "--diarize"]) @@ -364,10 +303,6 @@ def test_cli_verbose_reports_diarization_counts_and_duration(tmp_path): duration=10.0, ) result.words = [Word(0.0, 0.5, "Раз"), Word(0.5, 1.0, "два")] - model = _make_model() - backend = _make_backend() - backend.word_timestamps_available = True - tfr = _make_tfr(result=result, model=model, backend=backend) diarizer = MagicMock() diarizer.process.return_value = DiarizationRun( intervals=[ @@ -378,19 +313,11 @@ def test_cli_verbose_reports_diarization_counts_and_duration(tmp_path): ) with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", return_value=audio), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cpu", backend, "/models/medium"), - ), - patch("local_transcriber.cli._transcribe_file", return_value=tfr), + _cli_run(tmp_file=audio, result=result), patch( "local_transcriber.cli.load_speaker_diarizer", return_value=diarizer, ), - patch("local_transcriber.cli.write_transcript"), ): out = runner.invoke(app, [str(audio), "--diarize", "--verbose"]) @@ -403,26 +330,14 @@ def test_cli_empty_asr_skips_diarizer_and_reports_it(tmp_path): audio = tmp_path / "silence.wav" audio.write_bytes(b"fake") result = _make_result(segments=[]) - model = _make_model() - backend = _make_backend() - backend.word_timestamps_available = True - tfr = _make_tfr(result=result, model=model, backend=backend) diarizer = MagicMock() with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", return_value=audio), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cpu", backend, "/models/medium"), - ), - patch("local_transcriber.cli._transcribe_file", return_value=tfr), + _cli_run(tmp_file=audio, result=result), patch( "local_transcriber.cli.load_speaker_diarizer", return_value=diarizer, ), - patch("local_transcriber.cli.write_transcript"), ): out = runner.invoke(app, [str(audio), "--diarize"]) @@ -434,31 +349,37 @@ def test_cli_empty_asr_skips_diarizer_and_reports_it(tmp_path): def test_cli_diarizer_preflight_failure_does_not_start_asr_or_write(tmp_path): audio = tmp_path / "meeting.mp3" audio.write_bytes(b"fake") - model = _make_model() - backend = _make_backend() - backend.word_timestamps_available = True - transcribe_file = MagicMock() - write = MagicMock() with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", return_value=audio), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cpu", backend, "/models/medium"), - ), - patch("local_transcriber.cli._transcribe_file", transcribe_file), + _cli_run(tmp_file=audio) as (backend, write), patch( "local_transcriber.cli.load_speaker_diarizer", side_effect=RuntimeError("модель повреждена"), ), - patch("local_transcriber.cli.write_transcript", write), ): out = runner.invoke(app, [str(audio), "--diarize"]) assert out.exit_code == 1 - transcribe_file.assert_not_called() + backend.transcribe.assert_not_called() + write.assert_not_called() + + +def test_cli_diarize_rejects_model_without_word_timestamps_before_asr(tmp_path): + """Проверка пословного контракта проходит в module до диаризатора и ASR.""" + audio = tmp_path / "meeting.mp3" + audio.write_bytes(b"fake") + backend = _make_backend(word_timestamps=False) + + with ( + _cli_run(tmp_file=audio, backend=backend) as (backend, write), + patch("local_transcriber.cli.load_speaker_diarizer") as load_diarizer, + ): + out = runner.invoke(app, [str(audio), "--diarize"]) + + assert out.exit_code == 1 + assert "пословные таймкоды" in out.output + load_diarizer.assert_not_called() + backend.transcribe.assert_not_called() write.assert_not_called() @@ -479,28 +400,15 @@ def test_cli_unsuccessful_diarization_shape_writes_plain_text_and_exits_nonzero( duration=10.0, ) result.words = [Word(0.0, 0.5, "Раз"), Word(0.5, 1.0, "два")] - model = _make_model() - backend = _make_backend() - backend.word_timestamps_available = True - tfr = _make_tfr(result=result, model=model, backend=backend) diarizer = MagicMock() diarizer.process.return_value = DiarizationRun(intervals, elapsed_seconds=0.1) - write = MagicMock() with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", return_value=audio), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cpu", backend, "/models/medium"), - ), - patch("local_transcriber.cli._transcribe_file", return_value=tfr), + _cli_run(tmp_file=audio, result=result) as (_backend, write), patch( "local_transcriber.cli.load_speaker_diarizer", return_value=diarizer, ), - patch("local_transcriber.cli.write_transcript", write), ): out = runner.invoke(app, [str(audio), "--diarize"]) @@ -522,14 +430,8 @@ def test_cli_rejects_nonpositive_speaker_count(tmp_path): def test_cli_rejects_no_diarize_with_speakers_before_model_load(tmp_path): audio = tmp_path / "meeting.mp3" audio.write_bytes(b"fake") - load_model = MagicMock() - with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", return_value=audio), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch("local_transcriber.cli.load_model", load_model), - ): + with patch("local_transcriber.transcriber.get_backend") as get_backend: out = runner.invoke( app, [str(audio), "--no-diarize", "--speakers", "2"], @@ -537,34 +439,19 @@ def test_cli_rejects_no_diarize_with_speakers_before_model_load(tmp_path): assert out.exit_code == 2 assert "--no-diarize и --speakers несовместимы" in out.output - load_model.assert_not_called() + get_backend.assert_not_called() def test_cli_verbose_passes_on_segment_callback(tmp_path): audio = tmp_path / "test.mp3" audio.write_bytes(b"fake") - result = _make_result() - model = _make_model() - backend = _make_backend() - tfr = _make_tfr(result=result, model=model, backend=backend) - mock_transcribe_file = MagicMock(return_value=tfr) - with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", return_value=audio), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cpu", backend, "/models/medium"), - ), - patch("local_transcriber.cli._transcribe_file", mock_transcribe_file), - patch("local_transcriber.cli.write_transcript"), - ): + with _cli_run(tmp_file=audio) as (backend, _write): runner.invoke(app, [str(audio), "--verbose"]) - call_kwargs = mock_transcribe_file.call_args[1] - assert call_kwargs["on_segment"] is not None - assert callable(call_kwargs["on_segment"]) + on_segment = backend.transcribe.call_args[0][3] + assert on_segment is not None + assert callable(on_segment) def test_cli_empty_speech_warning(tmp_path): @@ -572,8 +459,7 @@ def test_cli_empty_speech_warning(tmp_path): audio.write_bytes(b"fake") result = _make_result(segments=[]) - patches = _single_patches(result=result, tmp_file=audio) - with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5]: + with _cli_run(tmp_file=audio, result=result): out = runner.invoke(app, [str(audio)]) assert out.exit_code == 0 @@ -583,27 +469,11 @@ def test_cli_empty_speech_warning(tmp_path): def test_cli_default_output_path(tmp_path): audio = tmp_path / "meeting.mp3" audio.write_bytes(b"fake") - mock_write = MagicMock() - result = _make_result() - model = _make_model() - backend = _make_backend() - tfr = _make_tfr(result=result, model=model, backend=backend) - - with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", return_value=audio), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cpu", backend, "/models/medium"), - ), - patch("local_transcriber.cli._transcribe_file", return_value=tfr), - patch("local_transcriber.cli.write_transcript", mock_write), - ): + with _cli_run(tmp_file=audio) as (_backend, write): runner.invoke(app, [str(audio)]) - written_path: Path = mock_write.call_args[0][1] + written_path: Path = write.call_args[0][1] assert written_path.name == "meeting-transcript.md" @@ -611,101 +481,46 @@ def test_cli_custom_output_path(tmp_path): audio = tmp_path / "meeting.mp3" audio.write_bytes(b"fake") out_file = tmp_path / "custom.md" - mock_write = MagicMock() - result = _make_result() - model = _make_model() - backend = _make_backend() - tfr = _make_tfr(result=result, model=model, backend=backend) - - with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", return_value=audio), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cpu", backend, "/models/medium"), - ), - patch("local_transcriber.cli._transcribe_file", return_value=tfr), - patch("local_transcriber.cli.write_transcript", mock_write), - ): + with _cli_run(tmp_file=audio) as (_backend, write): runner.invoke(app, [str(audio), "--output", str(out_file)]) - written_path: Path = mock_write.call_args[0][1] + written_path: Path = write.call_args[0][1] assert written_path == out_file def test_cli_passes_status_callback_to_transcribe(tmp_path): audio = tmp_path / "test.mp3" audio.write_bytes(b"fake") - result = _make_result() - model = _make_model() - backend = _make_backend() - tfr = _make_tfr(result=result, model=model, backend=backend) - mock_transcribe_file = MagicMock(return_value=tfr) - with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", return_value=audio), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cpu", backend, "/models/medium"), - ), - patch("local_transcriber.cli._transcribe_file", mock_transcribe_file), - patch("local_transcriber.cli.write_transcript"), - ): + with _cli_run(tmp_file=audio) as (backend, _write): runner.invoke(app, [str(audio)]) - call_kwargs = mock_transcribe_file.call_args[1] - assert call_kwargs["on_status"] is not None - assert callable(call_kwargs["on_status"]) + on_status = backend.transcribe.call_args[0][4] + assert on_status is not None + assert callable(on_status) def test_cli_load_model_called_with_model_name(tmp_path): - """load_model receives model name from defaults, handles ensure internally.""" + """ensure_model_available получает имя модели из флага CLI.""" audio = tmp_path / "test.mp3" audio.write_bytes(b"fake") - result = _make_result() - model = _make_model() - backend = _make_backend() - tfr = _make_tfr(result=result, model=model, backend=backend) - mock_load_model = MagicMock( - return_value=(model, "cpu", backend, "/models/large-v3") - ) - with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", return_value=audio), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch("local_transcriber.cli.load_model", mock_load_model), - patch("local_transcriber.cli._transcribe_file", return_value=tfr), - patch("local_transcriber.cli.write_transcript"), - ): + with _cli_run(tmp_file=audio) as (backend, _write): runner.invoke(app, [str(audio), "--model", "large-v3"]) - assert mock_load_model.call_args[0][0] == "large-v3" + assert backend.ensure_model_available.call_args[0][0] == "large-v3" def test_cli_windows_cuda_diagnostic(tmp_path): """При проблеме драйвера Windows не предлагает установку Toolkit.""" audio = tmp_path / "test.mp3" audio.write_bytes(b"fake") - model = _make_model() backend = _make_backend() + backend.transcribe.side_effect = RuntimeError("CUDA error: no device") with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", return_value=audio), - patch("local_transcriber.cli.detect_device", return_value="cuda"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cuda", backend, "/models/medium"), - ), - patch( - "local_transcriber.cli._transcribe_file", - side_effect=RuntimeError("CUDA error: no device"), - ), + _cli_run(tmp_file=audio, backend=backend), patch("local_transcriber.cli.sys") as mock_sys, ): mock_sys.platform = "win32" @@ -722,21 +537,11 @@ def test_cli_linux_cuda_error_no_windows_hint(tmp_path): """CUDA error on Linux does NOT print Windows-specific hint.""" audio = tmp_path / "test.mp3" audio.write_bytes(b"fake") - model = _make_model() backend = _make_backend() + backend.transcribe.side_effect = RuntimeError("CUDA error: no device") with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", return_value=audio), - patch("local_transcriber.cli.detect_device", return_value="cuda"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cuda", backend, "/models/medium"), - ), - patch( - "local_transcriber.cli._transcribe_file", - side_effect=RuntimeError("CUDA error: no device"), - ), + _cli_run(tmp_file=audio, backend=backend), patch("local_transcriber.cli.sys") as mock_sys, ): mock_sys.platform = "linux" @@ -855,96 +660,14 @@ def test_unsupported_compute_type_hint_requires_cuda_context( assert "Переключение на CPU" not in out.output -def test_cli_device_fallback_warning(tmp_path): - """When auto-detected device differs from actual, show fallback warning.""" - audio = tmp_path / "test.mp3" - audio.write_bytes(b"fake") - result = _make_result(device_used="cpu") - model = _make_model() - backend = _make_backend() - tfr = _make_tfr(result=result, model=model, actual_device="cpu", backend=backend) - - with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", return_value=audio), - patch("local_transcriber.cli.detect_device", return_value="cuda"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cuda", backend, "/models/medium"), - ), - patch("local_transcriber.cli._transcribe_file", return_value=tfr), - patch("local_transcriber.cli.write_transcript"), - ): - out = runner.invoke(app, [str(audio)]) - - assert "fallback" in out.output - - -def test_cli_strict_device_passed_to_transcribe(tmp_path): - """--device cuda passes strict_device=True; default auto passes False.""" - audio = tmp_path / "test.mp3" - audio.write_bytes(b"fake") - result = _make_result(device_used="cuda") - model = _make_model() - backend = _make_backend() - tfr = _make_tfr(result=result, model=model, actual_device="cuda", backend=backend) - mock_transcribe_file = MagicMock(return_value=tfr) - - with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", return_value=audio), - patch("local_transcriber.cli.detect_device", return_value="cuda"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cuda", backend, "/models/medium"), - ), - patch("local_transcriber.cli._transcribe_file", mock_transcribe_file), - patch("local_transcriber.cli.write_transcript"), - patch("local_transcriber.cli.get_gpu_name", return_value="RTX 3060"), - ): - runner.invoke(app, [str(audio), "--device", "cuda"]) - - assert mock_transcribe_file.call_args[1]["strict_device"] is True - - mock_transcribe_file.reset_mock() - result_cpu = _make_result(device_used="cpu") - tfr_cpu = _make_tfr(result=result_cpu, model=model, backend=backend) - mock_transcribe_file.return_value = tfr_cpu - - with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", return_value=audio), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cpu", backend, "/models/medium"), - ), - patch("local_transcriber.cli._transcribe_file", mock_transcribe_file), - patch("local_transcriber.cli.write_transcript"), - ): - runner.invoke(app, [str(audio)]) - - assert mock_transcribe_file.call_args[1]["strict_device"] is False - - def test_cli_keyboard_interrupt(tmp_path): """Ctrl+C → exit code 130, 'Прервано пользователем' in output.""" audio = tmp_path / "test.mp3" audio.write_bytes(b"fake") - model = _make_model() backend = _make_backend() + backend.transcribe.side_effect = KeyboardInterrupt - with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", return_value=audio), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cpu", backend, "/models/medium"), - ), - patch("local_transcriber.cli._transcribe_file", side_effect=KeyboardInterrupt), - patch("local_transcriber.cli.write_transcript"), - ): + with _cli_run(tmp_file=audio, backend=backend): out = runner.invoke(app, [str(audio)]) assert out.exit_code == 130 @@ -967,23 +690,10 @@ def test_cli_unexpected_error_verbose_traceback(tmp_path): """Unexpected error with --verbose → traceback shown.""" audio = tmp_path / "test.mp3" audio.write_bytes(b"fake") - model = _make_model() backend = _make_backend() + backend.transcribe.side_effect = RuntimeError("unexpected boom") - with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", return_value=audio), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cpu", backend, "/models/medium"), - ), - patch( - "local_transcriber.cli._transcribe_file", - side_effect=RuntimeError("unexpected boom"), - ), - patch("local_transcriber.cli.write_transcript"), - ): + with _cli_run(tmp_file=audio, backend=backend): out = runner.invoke(app, [str(audio), "--verbose"]) assert out.exit_code == 1 @@ -994,23 +704,10 @@ def test_cli_unexpected_error_no_verbose_hint(tmp_path): """Unexpected error without --verbose → hint to use --verbose.""" audio = tmp_path / "test.mp3" audio.write_bytes(b"fake") - model = _make_model() backend = _make_backend() + backend.transcribe.side_effect = RuntimeError("unexpected boom") - with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", return_value=audio), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cpu", backend, "/models/medium"), - ), - patch( - "local_transcriber.cli._transcribe_file", - side_effect=RuntimeError("unexpected boom"), - ), - patch("local_transcriber.cli.write_transcript"), - ): + with _cli_run(tmp_file=audio, backend=backend): out = runner.invoke(app, [str(audio)]) assert out.exit_code == 1 @@ -1022,31 +719,19 @@ def test_cli_unexpected_error_no_verbose_hint(tmp_path): def test_cli_batch_two_files(tmp_path): + """Два файла: одна загрузка модели, два распознавания.""" a = tmp_path / "a.mp3" b = tmp_path / "b.mp3" a.write_bytes(b"fake") b.write_bytes(b"fake") - result = _make_result() - model = _make_model() - backend = _make_backend() - tfr = _make_tfr(result=result, model=model, backend=backend) - - with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cpu", backend, "/models/medium"), - ), - patch("local_transcriber.cli._transcribe_file", return_value=tfr), - patch("local_transcriber.cli.write_transcript"), - ): + with _cli_run() as (backend, _write): out = runner.invoke(app, [str(a), str(b)]) assert out.exit_code == 0 assert "2 обработано" in out.output + assert backend.create_model.call_count == 1 + assert backend.transcribe.call_count == 2 @pytest.mark.parametrize( @@ -1065,10 +750,7 @@ def test_cli_batch_reuses_one_diarizer_for_all_nonempty_files( duration=10.0, ) result.words = [Word(0.0, 0.5, "Раз"), Word(0.5, 1.0, "два")] - model = _make_model() - backend = _make_backend() - backend.word_timestamps_available = True - tfr = _make_tfr(result=result, model=model, backend=backend) + backend = _make_backend(result=result) diarizer = MagicMock() diarizer.process.return_value = DiarizationRun( intervals=[ @@ -1079,22 +761,11 @@ def test_cli_batch_reuses_one_diarizer_for_all_nonempty_files( ) with ( - patch("local_transcriber.cli.load_config", return_value=config), - patch( - "local_transcriber.cli.validate_input_file", - side_effect=lambda path: path, - ), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cpu", backend, "/models/medium"), - ), - patch("local_transcriber.cli._transcribe_file", return_value=tfr), + _cli_run(backend=backend, config=config) as (_backend, write), patch( "local_transcriber.cli.load_speaker_diarizer", return_value=diarizer, ) as load_diarizer, - patch("local_transcriber.cli.write_transcript") as write, ): out = runner.invoke(app, [str(first), str(second), *cli_args]) @@ -1117,10 +788,7 @@ def test_cli_batch_continues_after_diarization_error_and_exits_nonzero(tmp_path) duration=10.0, ) result.words = [Word(0.0, 0.5, "Раз"), Word(0.5, 1.0, "два")] - model = _make_model() - backend = _make_backend() - backend.word_timestamps_available = True - tfr = _make_tfr(result=result, model=model, backend=backend) + backend = _make_backend(result=result) diarizer = MagicMock() diarizer.process.side_effect = [ RuntimeError("boom"), @@ -1134,22 +802,11 @@ def test_cli_batch_continues_after_diarization_error_and_exits_nonzero(tmp_path) ] with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch( - "local_transcriber.cli.validate_input_file", - side_effect=lambda path: path, - ), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cpu", backend, "/models/medium"), - ), - patch("local_transcriber.cli._transcribe_file", return_value=tfr), + _cli_run(backend=backend) as (_backend, write), patch( "local_transcriber.cli.load_speaker_diarizer", return_value=diarizer, ), - patch("local_transcriber.cli.write_transcript") as write, ): out = runner.invoke(app, [str(first), str(second), "--diarize"]) @@ -1167,22 +824,7 @@ def test_cli_batch_skips_existing(tmp_path): b.write_bytes(b"fake") (tmp_path / "a-transcript.md").write_text("existing") - result = _make_result() - model = _make_model() - backend = _make_backend() - tfr = _make_tfr(result=result, model=model, backend=backend) - - with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cpu", backend, "/models/medium"), - ), - patch("local_transcriber.cli._transcribe_file", return_value=tfr), - patch("local_transcriber.cli.write_transcript"), - ): + with _cli_run(): out = runner.invoke(app, [str(a), str(b)]) assert out.exit_code == 0 @@ -1199,23 +841,18 @@ def test_cli_batch_all_skipped_no_model_load(tmp_path): b.write_bytes(b"fake") (tmp_path / "b-transcript.md").write_text("existing") - mock_load_model = MagicMock() - mock_load_diarizer = MagicMock() - with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p), - patch("local_transcriber.cli.load_model", mock_load_model), - patch( - "local_transcriber.cli.load_speaker_diarizer", - mock_load_diarizer, - ), + _cli_run() as (backend, _write), + patch("local_transcriber.transcriber.get_backend") as get_backend, + patch("local_transcriber.cli.load_speaker_diarizer") as load_diarizer, ): out = runner.invoke(app, [str(a), str(b), "--diarize"]) assert out.exit_code == 0 - mock_load_model.assert_not_called() - mock_load_diarizer.assert_not_called() + assert "0 обработано, 2 пропущено" in out.output + get_backend.assert_not_called() + backend.create_model.assert_not_called() + load_diarizer.assert_not_called() def test_cli_batch_force_overwrites(tmp_path): @@ -1225,22 +862,7 @@ def test_cli_batch_force_overwrites(tmp_path): b = tmp_path / "b.mp3" b.write_bytes(b"fake") - result = _make_result() - model = _make_model() - backend = _make_backend() - tfr = _make_tfr(result=result, model=model, backend=backend) - - with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cpu", backend, "/models/medium"), - ), - patch("local_transcriber.cli._transcribe_file", return_value=tfr), - patch("local_transcriber.cli.write_transcript"), - ): + with _cli_run(): out = runner.invoke(app, [str(a), str(b), "--force"]) assert out.exit_code == 0 @@ -1253,33 +875,10 @@ def test_cli_batch_per_file_error(tmp_path): b = tmp_path / "b.mp3" a.write_bytes(b"fake") b.write_bytes(b"fake") - - result = _make_result() - model = _make_model() backend = _make_backend() - tfr = _make_tfr(result=result, model=model, backend=backend) - call_count = 0 + backend.transcribe.side_effect = [RuntimeError("oops"), _make_result()] - def transcribe_side_effect(**kwargs): - nonlocal call_count - call_count += 1 - if call_count == 1: - raise RuntimeError("oops") - return tfr - - with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cpu", backend, "/models/medium"), - ), - patch( - "local_transcriber.cli._transcribe_file", side_effect=transcribe_side_effect - ), - patch("local_transcriber.cli.write_transcript"), - ): + with _cli_run(backend=backend): out = runner.invoke(app, [str(a), str(b)]) assert out.exit_code == 1 @@ -1293,29 +892,9 @@ def test_cli_batch_invalid_in_prescan(tmp_path): b = tmp_path / "b.mp3" # b doesn't exist - result = _make_result() - model = _make_model() - backend = _make_backend() - tfr = _make_tfr(result=result, model=model, backend=backend) - - def validate_side_effect(p): - if not p.exists(): - raise FileNotFoundError(f"Файл не найден: {p}") - return p - with ( patch("local_transcriber.cli.load_config", return_value={}), - patch( - "local_transcriber.cli.validate_input_file", - side_effect=validate_side_effect, - ), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cpu", backend, "/models/medium"), - ), - patch("local_transcriber.cli._transcribe_file", return_value=tfr), - patch("local_transcriber.cli.write_transcript"), + patch("local_transcriber.transcriber.get_backend", return_value=_make_backend()), ): out = runner.invoke(app, [str(a), str(b)]) @@ -1350,24 +929,12 @@ def test_cli_batch_empty_glob(tmp_path, monkeypatch): def test_cli_config_applied(tmp_path): audio = tmp_path / "test.mp3" audio.write_bytes(b"fake") - model = _make_model() - backend = _make_backend() - result = _make_result() - tfr = _make_tfr(result=result, model=model, backend=backend) - mock_load_model = MagicMock(return_value=(model, "cpu", backend, "/models/tiny")) - with ( - patch("local_transcriber.cli.load_config", return_value={"model": "tiny"}), - patch("local_transcriber.cli.validate_input_file", return_value=audio), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch("local_transcriber.cli.load_model", mock_load_model), - patch("local_transcriber.cli._transcribe_file", return_value=tfr), - patch("local_transcriber.cli.write_transcript"), - ): + with _cli_run(tmp_file=audio, config={"model": "tiny"}) as (backend, _write): runner.invoke(app, [str(audio)]) - # load_model receives model name from config - assert mock_load_model.call_args[0][0] == "tiny" + # ensure_model_available receives model name from config + backend.ensure_model_available.assert_called_once_with("tiny", "int8", ANY) @pytest.mark.parametrize( @@ -1385,26 +952,11 @@ def test_cli_resolves_diarization_priority( audio = tmp_path / "meeting.mp3" audio.write_bytes(b"fake") result = _make_result(segments=[]) - model = _make_model() - backend = _make_backend() - backend.word_timestamps_available = True - tfr = _make_tfr(result=result, model=model, backend=backend) load_diarizer = MagicMock() with ( - patch( - "local_transcriber.cli.load_config", - return_value={"diarize": config_value}, - ), - patch("local_transcriber.cli.validate_input_file", return_value=audio), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cpu", backend, "/models/medium"), - ), - patch("local_transcriber.cli._transcribe_file", return_value=tfr), + _cli_run(tmp_file=audio, result=result, config={"diarize": config_value}), patch("local_transcriber.cli.load_speaker_diarizer", load_diarizer), - patch("local_transcriber.cli.write_transcript"), ): out = runner.invoke(app, [str(audio), *cli_args]) @@ -1415,91 +967,29 @@ def test_cli_resolves_diarization_priority( def test_cli_config_overrides_auto_device(tmp_path): audio = tmp_path / "test.mp3" audio.write_bytes(b"fake") - model = _make_model() - backend = _make_backend() - result = _make_result(device_used="openvino-cpu") - tfr = _make_tfr( - result=result, - model=model, - actual_device="openvino-cpu", - backend=backend, - ) - mock_detect_device = MagicMock(return_value="openvino-cpu") - mock_load_model = MagicMock( - return_value=(model, "openvino-cpu", backend, "/models/medium") - ) - with ( - patch( - "local_transcriber.cli.load_config", - return_value={ - "device": "openvino-cpu", - "model": "medium", - "compute_type": "int8", - }, - ), - patch("local_transcriber.cli.validate_input_file", return_value=audio), - patch("local_transcriber.cli.detect_device", mock_detect_device), - patch("local_transcriber.cli.load_model", mock_load_model), - patch("local_transcriber.cli._transcribe_file", return_value=tfr), - patch("local_transcriber.cli.write_transcript"), - ): + with _cli_run( + tmp_file=audio, + config={"device": "openvino-cpu", "model": "medium", "compute_type": "int8"}, + ) as (backend, _write): out = runner.invoke(app, [str(audio)]) assert out.exit_code == 0 - assert mock_detect_device.call_args_list[0].args == ("openvino-cpu",) - assert mock_load_model.call_args.args[:3] == ("medium", "openvino-cpu", "int8") + backend.ensure_model_available.assert_called_once_with("medium", "int8", ANY) + backend.create_model.assert_called_once_with( + "/models/medium", "openvino-cpu", "int8", cpu_threads=0 + ) def test_cli_cli_overrides_config(tmp_path): audio = tmp_path / "test.mp3" audio.write_bytes(b"fake") - model = _make_model() - backend = _make_backend() - result = _make_result() - tfr = _make_tfr(result=result, model=model, backend=backend) - mock_load_model = MagicMock(return_value=(model, "cpu", backend, "/models/small")) - with ( - patch("local_transcriber.cli.load_config", return_value={"model": "tiny"}), - patch("local_transcriber.cli.validate_input_file", return_value=audio), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch("local_transcriber.cli.load_model", mock_load_model), - patch("local_transcriber.cli._transcribe_file", return_value=tfr), - patch("local_transcriber.cli.write_transcript"), - ): + with _cli_run(tmp_file=audio, config={"model": "tiny"}) as (backend, _write): runner.invoke(app, [str(audio), "--model", "small"]) # CLI --model overrides config - assert mock_load_model.call_args[0][0] == "small" - - -def test_cli_batch_fallback_warning(tmp_path): - """Batch mode shows fallback warning when load_model falls back to CPU.""" - a = tmp_path / "a.mp3" - b = tmp_path / "b.mp3" - a.write_bytes(b"fake") - b.write_bytes(b"fake") - - result = _make_result(device_used="cpu") - model = _make_model() - backend = _make_backend() - tfr = _make_tfr(result=result, model=model, actual_device="cpu", backend=backend) - - with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p), - patch("local_transcriber.cli.detect_device", return_value="cuda"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cpu", backend, "/models/medium"), - ), - patch("local_transcriber.cli._transcribe_file", return_value=tfr), - patch("local_transcriber.cli.write_transcript"), - ): - out = runner.invoke(app, [str(a), str(b)]) - - assert "fallback" in out.output + backend.ensure_model_available.assert_called_once_with("small", "int8", ANY) def test_cli_batch_empty_speech_warning(tmp_path): @@ -1511,24 +1001,10 @@ def test_cli_batch_empty_speech_warning(tmp_path): result_empty = _make_result(segments=[]) result_ok = _make_result() - model = _make_model() backend = _make_backend() - tfr_empty = _make_tfr(result=result_empty, model=model, backend=backend) - tfr_ok = _make_tfr(result=result_ok, model=model, backend=backend) + backend.transcribe.side_effect = [result_empty, result_ok] - with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cpu", backend, "/models/medium"), - ), - patch( - "local_transcriber.cli._transcribe_file", side_effect=[tfr_empty, tfr_ok] - ), - patch("local_transcriber.cli.write_transcript"), - ): + with _cli_run(backend=backend): out = runner.invoke(app, [str(a), str(b)]) assert out.exit_code == 0 @@ -1536,209 +1012,86 @@ def test_cli_batch_empty_speech_warning(tmp_path): assert "2 обработано" in out.output -def test_cli_batch_midstream_fallback_warning(tmp_path): - """Batch mode shows warning when _transcribe_file falls back mid-stream.""" - a = tmp_path / "a.mp3" - b = tmp_path / "b.mp3" - a.write_bytes(b"fake") - b.write_bytes(b"fake") - - model_gpu = _make_model() - model_cpu = _make_model() - backend = _make_backend() - result = _make_result(device_used="cpu") - tfr_fallback = _make_tfr( - result=result, model=model_cpu, actual_device="cpu", backend=backend - ) - tfr_ok = _make_tfr( - result=result, model=model_cpu, actual_device="cpu", backend=backend - ) - - with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p), - patch("local_transcriber.cli.detect_device", return_value="cuda"), - patch( - "local_transcriber.cli.load_model", - return_value=(model_gpu, "cuda", backend, "/models/medium"), - ), - patch( - "local_transcriber.cli._transcribe_file", side_effect=[tfr_fallback, tfr_ok] - ), - patch("local_transcriber.cli.write_transcript"), - ): - out = runner.invoke(app, [str(a), str(b)]) - - assert "fallback" in out.output - assert "2 обработано" in out.output - - -def test_cli_batch_model_loaded_once(tmp_path): - a = tmp_path / "a.mp3" - b = tmp_path / "b.mp3" - a.write_bytes(b"fake") - b.write_bytes(b"fake") - - result = _make_result() - model = _make_model() - backend = _make_backend() - tfr = _make_tfr(result=result, model=model, backend=backend) - mock_load_model = MagicMock(return_value=(model, "cpu", backend, "/models/medium")) - - with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch("local_transcriber.cli.load_model", mock_load_model), - patch("local_transcriber.cli._transcribe_file", return_value=tfr), - patch("local_transcriber.cli.write_transcript"), - ): - out = runner.invoke(app, [str(a), str(b)]) - - assert out.exit_code == 0 - mock_load_model.assert_called_once() - - -# === _format_device_info tests === - - -def test_format_device_info_openvino_gpu(): - with patch( - "local_transcriber.cli.get_intel_gpu_name", - return_value="Intel(R) Arc(TM) 140T GPU", - ): - assert ( - _format_device_info("openvino-gpu") - == "OpenVINO (Intel(R) Arc(TM) 140T GPU)" - ) - - -def test_format_device_info_openvino_gpu_no_name(): - """get_intel_gpu_name вернул None → fallback на 'Intel GPU'.""" - with patch("local_transcriber.cli.get_intel_gpu_name", return_value=None): - assert _format_device_info("openvino-gpu") == "OpenVINO (Intel GPU)" - - -def test_format_device_info_openvino_cpu(): - assert _format_device_info("openvino-cpu") == "OpenVINO (CPU)" - - -def test_format_device_info_openvino_legacy(): - """Обратная совместимость: 'openvino' → OpenVINO (CPU).""" - assert _format_device_info("openvino") == "OpenVINO (CPU)" - - -def test_format_device_info_distinguishes_onnx_from_faster_whisper_cpu(): - assert _format_device_info("onnx") == "ONNX (CPU)" - assert _format_device_info("cpu") == "CPU" - - -def test_format_device_info_cuda(): - with patch("local_transcriber.cli.get_gpu_name", return_value="RTX 4090"): - assert _format_device_info("cuda") == "CUDA (RTX 4090)" - - # === CLI with --device openvino-gpu === def test_cli_openvino_gpu_happy_path(tmp_path): audio = tmp_path / "test.mp3" audio.write_bytes(b"fake") - result = _make_result(device_used="openvino-gpu") + backend = _make_backend(actual_ov_device="GPU") - patches = _single_patches( - result=result, tmp_file=audio, actual_device="openvino-gpu" - ) - with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5]: - with patch( - "local_transcriber.cli.get_intel_gpu_name", return_value="Intel Arc 140T" - ): - out = runner.invoke(app, [str(audio), "--device", "openvino-gpu"]) + with ( + _cli_run(tmp_file=audio, backend=backend), + patch( + "local_transcriber.transcriber.get_intel_gpu_name", + return_value="Intel Arc 140T", + ), + ): + out = runner.invoke(app, [str(audio), "--device", "openvino-gpu"]) assert out.exit_code == 0 def test_cli_openvino_alias_resolves_to_gpu(tmp_path): - """--device openvino резолвится через detect_device в openvino-gpu.""" + """--device openvino резолвится в openvino-gpu, шапка берёт имя GPU из данных module.""" audio = tmp_path / "test.mp3" audio.write_bytes(b"fake") - result = _make_result(device_used="openvino-gpu") - model = _make_model() - backend = _make_backend() - tfr = _make_tfr( - result=result, model=model, actual_device="openvino-gpu", backend=backend - ) - mock_load_model = MagicMock( - return_value=(model, "openvino-gpu", backend, "/models/medium") - ) + backend = _make_backend(actual_ov_device="GPU") with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", return_value=audio), - patch("local_transcriber.cli.detect_device", return_value="openvino-gpu"), - patch("local_transcriber.cli.load_model", mock_load_model), - patch("local_transcriber.cli._transcribe_file", return_value=tfr), - patch("local_transcriber.cli.write_transcript"), + _cli_run(tmp_file=audio, backend=backend, write=False), + patch("local_transcriber.transcriber.detect_device", return_value="openvino-gpu"), patch( - "local_transcriber.cli.get_intel_gpu_name", return_value="Intel Arc 140T" + "local_transcriber.transcriber.get_intel_gpu_name", + return_value="Intel Arc 140T", ), ): out = runner.invoke(app, [str(audio), "--device", "openvino"]) + content = (tmp_path / "test-transcript.md").read_text(encoding="utf-8") assert out.exit_code == 0 - # detect_device("openvino") resolved to "openvino-gpu", load_model receives it - assert mock_load_model.call_args[0][1] == "openvino-gpu" + assert backend.create_model.call_args[0][1] == "openvino-gpu" + assert "- **Устройство**: OpenVINO (Intel Arc 140T)" in content -# === --threads === - - -def test_cli_threads_passed_to_load_model(tmp_path): - """--threads передаётся в load_model как cpu_threads.""" +def test_cli_reports_openvino_cpu_when_gpu_was_requested(tmp_path): + """OpenVINO, выбравший CPU вместо запрошенного GPU, виден пользователю.""" audio = tmp_path / "test.mp3" audio.write_bytes(b"fake") - result = _make_result() - model = _make_model() - backend = _make_backend() - tfr = _make_tfr(result=result, model=model, backend=backend) - mock_load_model = MagicMock(return_value=(model, "cpu", backend, "/models/medium")) + backend = _make_backend(actual_ov_device="CPU") with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", return_value=audio), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch("local_transcriber.cli.load_model", mock_load_model), - patch("local_transcriber.cli._transcribe_file", return_value=tfr), - patch("local_transcriber.cli.write_transcript"), + _cli_run(tmp_file=audio, backend=backend), + patch("local_transcriber.transcriber.detect_device", return_value="openvino-gpu"), ): + out = runner.invoke(app, [str(audio), "--device", "openvino"]) + + assert out.exit_code == 0 + assert "Запрошено openvino, используется openvino-cpu" in out.output + + +def test_cli_threads_reach_adapter(tmp_path): + """--threads передаётся в create_model как cpu_threads.""" + audio = tmp_path / "test.mp3" + audio.write_bytes(b"fake") + + with _cli_run(tmp_file=audio) as (backend, _write): out = runner.invoke(app, [str(audio), "--threads", "8"]) assert out.exit_code == 0 - assert mock_load_model.call_args.kwargs["cpu_threads"] == 8 + assert backend.create_model.call_args.kwargs["cpu_threads"] == 8 def test_cli_threads_default_zero(tmp_path): - """Без --threads load_model получает cpu_threads=0.""" + """Без --threads create_model получает cpu_threads=0.""" audio = tmp_path / "test.mp3" audio.write_bytes(b"fake") - result = _make_result() - model = _make_model() - backend = _make_backend() - tfr = _make_tfr(result=result, model=model, backend=backend) - mock_load_model = MagicMock(return_value=(model, "cpu", backend, "/models/medium")) - with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", return_value=audio), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch("local_transcriber.cli.load_model", mock_load_model), - patch("local_transcriber.cli._transcribe_file", return_value=tfr), - patch("local_transcriber.cli.write_transcript"), - ): + with _cli_run(tmp_file=audio) as (backend, _write): out = runner.invoke(app, [str(audio)]) assert out.exit_code == 0 - assert mock_load_model.call_args.kwargs["cpu_threads"] == 0 + assert backend.create_model.call_args.kwargs["cpu_threads"] == 0 def test_cli_threads_negative_rejected(tmp_path): @@ -1861,14 +1214,8 @@ def test_cli_tail_gap_quality_warning_single(tmp_path): duration=600.0, ) - patches = _single_patches(result=result, tmp_file=audio) with ( - patches[0], - patches[1], - patches[2], - patches[3], - patches[4], - patches[5], + _cli_run(tmp_file=audio, result=result), patch("local_transcriber.cli.console", Console(stderr=True, width=1000)), ): out = runner.invoke(app, [str(audio)]) @@ -1893,14 +1240,8 @@ def test_cli_repetition_quality_warning_single(tmp_path): duration=60.0, ) - patches = _single_patches(result=result, tmp_file=audio) with ( - patches[0], - patches[1], - patches[2], - patches[3], - patches[4], - patches[5], + _cli_run(tmp_file=audio, result=result), patch("local_transcriber.cli.console", Console(stderr=True, width=1000)), ): out = runner.invoke(app, [str(audio)]) @@ -1923,21 +1264,11 @@ def test_cli_quality_warning_batch_includes_file_name(tmp_path): duration=600.0, ) result_ok = _make_result() - model = _make_model() backend = _make_backend() - tfr_warn = _make_tfr(result=result_warn, model=model, backend=backend) - tfr_ok = _make_tfr(result=result_ok, model=model, backend=backend) + backend.transcribe.side_effect = [result_warn, result_ok] with ( - patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p), - patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch( - "local_transcriber.cli.load_model", - return_value=(model, "cpu", backend, "/models/medium"), - ), - patch("local_transcriber.cli._transcribe_file", side_effect=[tfr_warn, tfr_ok]), - patch("local_transcriber.cli.write_transcript"), + _cli_run(backend=backend), patch("local_transcriber.cli.console", Console(stderr=True, width=1000)), ): out = runner.invoke(app, [str(a), str(b)]) @@ -1971,14 +1302,8 @@ def test_cli_repetition_quality_warning_truncates_after_three_blocks(tmp_path): duration=90.0, ) - patches = _single_patches(result=result, tmp_file=audio) with ( - patches[0], - patches[1], - patches[2], - patches[3], - patches[4], - patches[5], + _cli_run(tmp_file=audio, result=result), patch("local_transcriber.cli.console", Console(stderr=True, width=1000)), ): out = runner.invoke(app, [str(audio)]) @@ -1995,10 +1320,39 @@ def test_cli_default_result_has_no_quality_warnings(tmp_path): audio = tmp_path / "normal.mp3" audio.write_bytes(b"fake") - patches = _single_patches(tmp_file=audio) - with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5]: + with _cli_run(tmp_file=audio): out = runner.invoke(app, [str(audio)]) assert out.exit_code == 0 assert "потеря хвоста" not in out.output assert "галлюцинации" not in out.output + + +def test_cli_toml_compute_type_overrides_device_default(tmp_path): + """compute_type из TOML важнее умолчания устройства и считается явным.""" + audio = tmp_path / "test.mp3" + audio.write_bytes(b"fake") + + with ( + _cli_run(tmp_file=audio, config={"device": "cuda", "compute_type": "int8"}) as ( + backend, + _write, + ), + patch("local_transcriber.transcriber.get_backend", return_value=backend) as get_backend, + ): + out = runner.invoke(app, [str(audio)]) + + assert out.exit_code == 0 + get_backend.assert_called_once_with("cuda", compute_type_explicit=True) + backend.ensure_model_available.assert_called_once_with("medium", "int8", ANY) + + +def test_cli_device_default_compute_type_when_not_configured(tmp_path): + audio = tmp_path / "test.mp3" + audio.write_bytes(b"fake") + + with _cli_run(tmp_file=audio, config={"device": "cuda"}) as (backend, _write): + out = runner.invoke(app, [str(audio)]) + + assert out.exit_code == 0 + backend.ensure_model_available.assert_called_once_with("medium", "float16", ANY) diff --git a/tests/test_config.py b/tests/test_config.py index 2408a01..9e44eea 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,7 +3,6 @@ from unittest.mock import patch import pytest from local_transcriber.config import ( - apply_device_defaults, find_config_file, load_config, resolve_defaults, @@ -107,15 +106,15 @@ def test_resolve_defaults_config_wins(): assert result["model"] == "tiny" -def test_resolve_defaults_hardcoded_fallback(): +def test_resolve_defaults_leaves_device_aware_keys_empty(): result = resolve_defaults( {"model": None, "language": None, "device": None, "compute_type": None}, {} ) assert result == { - "model": "medium", + "model": None, "language": "ru", "device": "auto", - "compute_type": "float32", + "compute_type": None, "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 -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): config = tmp_path / "config.toml" 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') result = load_config(config) 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" diff --git a/tests/test_onnx_asr.py b/tests/test_onnx_asr.py index 118d7b6..05486df 100644 --- a/tests/test_onnx_asr.py +++ b/tests/test_onnx_asr.py @@ -108,6 +108,36 @@ class TestCreateModel: ) 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): timestamped_model = object() @@ -659,3 +689,26 @@ class TestModelAliases: assert "--device cpu --model medium" in message assert "--device cuda --model large-v3-turbo" not in message assert "--device cpu --model large-v3-turbo" not in message + + +class TestRuntimeInfo: + def test_reports_versions_and_configured_providers(self, monkeypatch): + """Диагностика различает доступные providers и те, что заданы сессиям ASR и VAD.""" + monkeypatch.setattr( + "onnxruntime.get_available_providers", + lambda: ["CoreMLExecutionProvider", "CPUExecutionProvider"], + ) + monkeypatch.setattr("onnx_asr.load_model", lambda **kwargs: MagicMock()) + monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kwargs: MagicMock()) + backend = OnnxAsrBackend() + backend.actual_compute_type = "int8" + backend.create_model("gigaam-v3-e2e-rnnt", "onnx", "int8") + + info = backend.runtime_info() + + assert info["onnxruntime"] + assert info["onnx_asr"] + assert info["available_providers"] == "CoreMLExecutionProvider, CPUExecutionProvider" + assert info["asr_providers"] == "CPUExecutionProvider" + assert info["vad_providers"] == "CPUExecutionProvider" + assert info["quantization"] == "int8" diff --git a/tests/test_transcriber.py b/tests/test_transcriber.py index 4212e6a..3f5e929 100644 --- a/tests/test_transcriber.py +++ b/tests/test_transcriber.py @@ -4,12 +4,12 @@ from unittest.mock import MagicMock, patch import pytest from local_transcriber.transcriber import ( + ExecutionRequest, Segment, + Transcriber, TranscribeResult, - _transcribe_file, cuda_error_hint, ensure_model_available, - load_model, transcribe, ) from local_transcriber.types import WordTimestampsUnavailableError @@ -232,7 +232,7 @@ def test_transcribe_reports_status_transitions(mock_get_backend): 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) @@ -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) === @@ -492,202 +397,6 @@ def test_ensure_model_available_rejects_incomplete_local_directory(tmp_path): 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(): """ensure_model_available(device='openvino-cpu') без compute_type не падает.""" 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") assert repo == "OpenVINO/whisper-medium-int8-ov" assert ct == "int8" + + +# === Transcriber: module выполнения === + + +def _make_run_backend(**kwargs): + """Fake adapter для external interface: runtime_info возвращает словарь.""" + backend = _make_backend(**kwargs) + backend.runtime_info.return_value = {} + backend.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"]