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