Compare commits
19
Commits
f3d0293120
...
master
No files matched your search
@@ -25,11 +25,12 @@ Package manager is **uv** (not pip). Build backend is hatchling.
|
||||
|
||||
```
|
||||
CLI (cli.py)
|
||||
→ config.py cascade: CLI arg → .transcriber.toml → device-aware default → hardcoded
|
||||
→ config.py cascade: CLI arg → .transcriber.toml (model/compute_type stay None for the module to resolve)
|
||||
→ utils.py detect_device(), validate files, expand globs (Windows workaround)
|
||||
→ context_menu.py Windows SendTo: Transcribe.cmd install/uninstall (--install-menu / --uninstall-menu)
|
||||
→ transcriber.py load_model() → get_backend(device) → ensure_model_available → create_model
|
||||
_transcribe_file() with mid-stream CUDA→CPU fallback
|
||||
→ transcriber.py Transcriber(ExecutionRequest): resolves auto + device defaults,
|
||||
get_backend(device) → ensure_model_available → create_model once per run,
|
||||
transcribe(file) per file; fallback and ExecutionInfo stay inside
|
||||
→ formatter.py segments → markdown with timestamps, paragraph grouping (>2s pause or >60s)
|
||||
```
|
||||
|
||||
@@ -47,19 +48,21 @@ Three backends implement the `Backend` Protocol (structural typing, no inheritan
|
||||
|
||||
### Key design decisions
|
||||
|
||||
- **Two-level fallback**: GPU→CPU at model load time AND mid-stream during transcription (GPU visible via nvidia-smi but insufficient VRAM).
|
||||
- **Execution module** (`Transcriber`): one owner per run for model handle, adapter, actual device and allowed GPU→CPU fallback (load time and mid-stream). Callers get `TranscribeResult` + `ExecutionInfo` (requested vs actual device, engine, model, compute type, threads, `runtime_info()` from the adapter for `--verbose`). Fallback is reachable only with `strict_device=False` (Python API): CLI explicit device is strict, `auto` is ONNX CPU.
|
||||
- **CUDA bootstrap** (`_cuda_bootstrap.py`): preloads `libcublas.so.12` via `ctypes.CDLL(RTLD_GLOBAL)` before importing ctranslate2, because pip's `nvidia-cublas-cu12` installs to a non-standard path and `LD_LIBRARY_PATH` can't be changed at runtime (glibc caches it).
|
||||
- **Batch mode**: 3-phase pipeline (prescan → load model once → transcribe all). `TranscribeFileResult` carries updated model/backend/device state between files.
|
||||
- **Device-aware defaults**: `compute_type` and `model` vary by device (float16 for CUDA, int8 for OpenVINO, float32 for CPU). Defined in `config.py` `DEVICE_DEFAULTS`.
|
||||
- **Batch mode**: 3-phase pipeline (prescan → create `Transcriber` → transcribe all). No state is carried between files by the CLI.
|
||||
- **Device-aware defaults**: `compute_type` and `model` vary by device (float16 for CUDA, int8 for OpenVINO, float32 for CPU). Defined in `config.py` `DEVICE_DEFAULTS`, applied by `Transcriber` when the request leaves them `None`.
|
||||
- **ONNX thread budget**: `--threads` sets `intra_op_num_threads` for both ASR and VAD sessions via `SessionOptions`; 0 keeps onnxruntime defaults.
|
||||
- **OpenVINO uses pre-quantized models** — `compute_type` selects which HF repo to download, not a runtime parameter.
|
||||
|
||||
## Testing
|
||||
|
||||
All tests mock backends — no real model downloads or transcription. Key test patterns:
|
||||
|
||||
- CLI tests: `typer.testing.CliRunner` + mocks for `load_config`, `detect_device`, `load_model`, `_transcribe_file`, `write_transcript`
|
||||
- `_single_patches()` — helper assembling standard happy-path mock set
|
||||
- `_make_result()` / `_make_tfr()` — factories for test data
|
||||
- CLI tests: `typer.testing.CliRunner` with the real `Transcriber` and a fake adapter patched at `local_transcriber.transcriber.get_backend`; `load_config`, `validate_input_file`, `write_transcript` mocked
|
||||
- `_cli_run()` — context manager assembling that standard set, yields `(backend, write_transcript)`
|
||||
- `_make_result()` / `_make_backend()` — factories for test data
|
||||
- Module tests (`test_transcriber.py`): `Transcriber` through its interface with `_make_run_backend()`; adapter tests patch the libraries (`onnx_asr.load_model`, `faster_whisper.WhisperModel`)
|
||||
|
||||
## Common tasks
|
||||
|
||||
@@ -79,7 +82,7 @@ All tests mock backends — no real model downloads or transcription. Key test p
|
||||
|
||||
### Issue tracker
|
||||
|
||||
Задачи ведутся в Gitea через `tea`; GitHub используется только как зеркало, внешние PR не входят в triage. См. `docs/agents/issue-tracker.md`.
|
||||
Задачи ведутся в Gitea через `tea`; внешние PR не входят в triage. См. `docs/agents/issue-tracker.md`.
|
||||
|
||||
### Triage labels
|
||||
|
||||
|
||||
@@ -1 +1,21 @@
|
||||
@AGENTS.md
|
||||
|
||||
# Стиль изложения
|
||||
|
||||
Пиши легко для чтения. Сокращай количество мыслей, а не количество слов, которыми выражена каждая мысль.
|
||||
Please remove all mannered prose.
|
||||
|
||||
# Выбрать подход и держаться его
|
||||
|
||||
Выбирая, как решать задачу, возьми один подход и доведи его до конца. Не пересматривай
|
||||
решение, пока не появится новая информация, которая прямо противоречит твоим
|
||||
рассуждениям. Если колеблешься между двумя подходами, выбери один и посмотри, куда он
|
||||
приведёт; свернуть можно позже, если выбранный не сработал.
|
||||
|
||||
# Для моделей Fable 5 и 5.1
|
||||
|
||||
При прочих равных экономь токены на правке файлов: когда результат не меняется, правь
|
||||
файл точечно, а не переписывай целиком.
|
||||
|
||||
Сначала про себя составь список того, что нужно дальше; затем в одном ответе запроси
|
||||
всё, что не зависит от результата другого запроса.
|
||||
+8
-8
@@ -43,25 +43,25 @@ tests/
|
||||
CLI (cli.py)
|
||||
→ config.py: загрузка .transcriber.toml, каскад дефолтов
|
||||
→ utils.py: валидация файлов, определение устройства
|
||||
→ transcriber.py: загрузка модели (с CUDA fallback), транскрипция
|
||||
→ transcriber.py: Transcriber — разрешение устройства, загрузка модели один раз на запуск, транскрипция, fallback
|
||||
→ formatter.py: сегменты → markdown с таймкодами
|
||||
→ запись результата
|
||||
```
|
||||
|
||||
## Ключевые архитектурные решения
|
||||
|
||||
- **CUDA fallback** — двухуровневый: при загрузке модели и при транскрипции (mid-stream). GPU может быть видна через nvidia-smi, но не иметь достаточно VRAM.
|
||||
- **Device-aware дефолты** — `compute_type` зависит от устройства (`float16`/`float32`). `float16` не работает на CPU, `float32` расточителен на GPU.
|
||||
- **Module выполнения** (`Transcriber` в `transcriber.py`) — один владелец модели, adapter'а и фактического исполнения на запуск. Fallback GPU→CPU (при загрузке и mid-stream) живёт внутри и доступен только при `strict_device=False` из Python API: явный device в CLI strict, `auto` — ONNX CPU.
|
||||
- **Device-aware дефолты** — `model` и `compute_type` без явного значения разрешаются по устройству при загрузке (`float16`/`float32`). `float16` не работает на CPU, `float32` расточителен на GPU.
|
||||
- **cuBLAS bootstrap** (`_cuda_bootstrap.py`) — preload через ctypes до импорта ctranslate2. pip-пакет `nvidia-cublas-cu12` ставит `.so` в нестандартное место, а `LD_LIBRARY_PATH` нельзя изменить в рантайме.
|
||||
- **Батч-режим** — 3 фазы (prescan → load model → transcribe). Модель загружается один раз (~2-5 сек), невалидные файлы отсеиваются до загрузки.
|
||||
- **Батч-режим** — 3 фазы (prescan → создание `Transcriber` → transcribe). Модель загружается один раз (~2-5 сек), невалидные файлы отсеиваются до загрузки; CLI не переносит состояние между файлами.
|
||||
- **Ручной glob в utils** — typer на Windows не раскрывает `*.mp4`, поэтому глобы обрабатываются явно.
|
||||
|
||||
## Тестирование
|
||||
|
||||
- Все CLI-тесты через `typer.testing.CliRunner` + моки (faster-whisper не вызывается)
|
||||
- Моки: `load_config`, `validate_input_file`, `detect_device`, `ensure_model_available`, `load_model`, `_transcribe_file`, `write_transcript`
|
||||
- Паттерн: `_single_patches()` — хелпер для стандартного happy-path набора моков
|
||||
- `_make_result()` / `_make_tfr()` — фабрики тестовых данных
|
||||
- Все CLI-тесты через `typer.testing.CliRunner` с настоящим `Transcriber` и fake adapter, подменённым в `local_transcriber.transcriber.get_backend` (библиотеки не вызываются)
|
||||
- Моки: `load_config`, `validate_input_file`, `write_transcript`
|
||||
- Паттерн: `_cli_run()` — контекстный менеджер стандартного набора, отдаёт `(backend, write_transcript)`
|
||||
- `_make_result()` / `_make_backend()` — фабрики тестовых данных
|
||||
|
||||
## Частые задачи
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ transcribe meeting.mp4
|
||||
```
|
||||
|
||||
- **Полностью локально** — данные не покидают машину
|
||||
- **Авто-ускорение** — NVIDIA CUDA при наличии GPU, иначе ONNX на CPU
|
||||
- **ONNX на CPU по умолчанию** — CUDA и OpenVINO включаются явно
|
||||
- **Батч-режим** — обработка нескольких файлов за один вызов
|
||||
- **Разделение говорящих** — локальная диаризация по флагу `--diarize`
|
||||
- **Из проводника Windows** — пункт Transcribe в меню «Отправить» ([установка](#контекстное-меню-проводника-windows))
|
||||
@@ -26,26 +26,42 @@ curl -LsSf https://astral.sh/uv/install.sh | sh # Linux / macOS
|
||||
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" # Windows
|
||||
```
|
||||
|
||||
**2. Установить transcriber:**
|
||||
После установки откройте новую консоль, чтобы перечитались переменные окружения.
|
||||
|
||||
**2. Установить transcriber.** Напрямую из git, без скачивания исходников:
|
||||
|
||||
```bash
|
||||
uv tool install --python 3.13 git+https://git.dementev.space/ddmitry/local-transcriber.git
|
||||
uv tool install --python 3.13 git+https://github.com/dementev-dev/local-transcriber
|
||||
```
|
||||
|
||||
**3. Ускорение (ставится автоматически):**
|
||||
Или из клона репозитория (`<URL>` — адрес репозитория, из которого вы ставите):
|
||||
|
||||
- **NVIDIA CUDA** (GPU): если есть GPU — транскрипция в 5-10× быстрее
|
||||
- **Windows**: `winget install -e --id Nvidia.CUDA --version 12.9` (от администратора), перезапустить терминал
|
||||
- **Linux / WSL2**: работает из коробки (нужен только драйвер: `nvidia-smi`)
|
||||
- **Без NVIDIA GPU**: автоматически используется ONNX с GigaAM RNN-T на CPU.
|
||||
Эта модель понимает только русскую речь
|
||||
- **Другие языки без NVIDIA**: выбирайте Whisper явно —
|
||||
`--device openvino-cpu --model medium` на x86 или `--device cpu --model medium`
|
||||
на любой платформе
|
||||
- **OpenVINO** для Intel GPU или x86 CPU остаётся доступен через явный
|
||||
`--device openvino`, `--device openvino-gpu` или `--device openvino-cpu`
|
||||
```bash
|
||||
git clone <URL>
|
||||
cd local-transcriber
|
||||
uv tool install --python 3.13 .
|
||||
```
|
||||
|
||||
**4. Готово:**
|
||||
**3. Пункт `Transcribe` в меню «Отправить»** (Windows, по желанию):
|
||||
|
||||
```bash
|
||||
transcribe --install-menu
|
||||
```
|
||||
|
||||
Подробнее — в разделе [контекстное меню проводника](#контекстное-меню-проводника-windows).
|
||||
|
||||
**4. Выбор исполнения:**
|
||||
|
||||
- По умолчанию используется ONNX с GigaAM RNN-T на CPU, независимо от наличия
|
||||
NVIDIA. Эта модель понимает только русскую речь.
|
||||
- Для других языков выбирайте Whisper явно:
|
||||
`--device openvino-cpu --model medium` на x86 или `--device cpu --model medium`.
|
||||
- OpenVINO для Intel GPU или x86 CPU доступен через `--device openvino`,
|
||||
`--device openvino-gpu` или `--device openvino-cpu`.
|
||||
- Для NVIDIA установите дополнительные библиотеки и выберите CUDA явно:
|
||||
[подключение CUDA](#подключение-cuda).
|
||||
|
||||
**5. Готово:**
|
||||
|
||||
```bash
|
||||
transcribe meeting.mp4
|
||||
@@ -63,7 +79,14 @@ transcribe meeting.mp4
|
||||
**Обновление:**
|
||||
|
||||
```bash
|
||||
uv tool install --python 3.13 --force git+https://git.dementev.space/ddmitry/local-transcriber.git
|
||||
uv tool install --python 3.13 --force git+https://github.com/dementev-dev/local-transcriber
|
||||
```
|
||||
|
||||
Или в папке со склонированным репозиторием:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
uv tool install --python 3.13 --force .
|
||||
```
|
||||
|
||||
**Удаление:**
|
||||
@@ -107,17 +130,74 @@ HuggingFace Hub использует симлинки для экономии м
|
||||
|
||||
</details>
|
||||
|
||||
## Подключение CUDA
|
||||
|
||||
Обычная установка не требует NVIDIA-пакетов. Extra `cuda` добавляет cuBLAS
|
||||
в окружение приложения на Linux/WSL x86_64 и Windows x64. Полный системный
|
||||
CUDA Toolkit и ручная правка системного PATH для этого пути не нужны.
|
||||
Совместимые GPU и драйвер NVIDIA необходимы отдельно: extra не устанавливает
|
||||
драйвер и не исправляет несовместимость старой видеокарты.
|
||||
|
||||
Для установки из Git или подключения CUDA к уже установленной программе:
|
||||
|
||||
```bash
|
||||
uv tool install --python 3.13 --force "local-transcriber[cuda] @ git+https://github.com/dementev-dev/local-transcriber"
|
||||
```
|
||||
|
||||
Из клона репозитория:
|
||||
|
||||
```bash
|
||||
uv tool install --python 3.13 --force ".[cuda]"
|
||||
```
|
||||
|
||||
Для локальной разработки:
|
||||
|
||||
```bash
|
||||
uv sync --extra cuda
|
||||
uv run --extra cuda transcribe meeting.mp4 --device cuda
|
||||
```
|
||||
|
||||
После установки extra выберите CUDA при запуске:
|
||||
|
||||
```bash
|
||||
transcribe meeting.mp4 --device cuda
|
||||
```
|
||||
|
||||
Либо задайте в `.transcriber.toml`:
|
||||
|
||||
```toml
|
||||
device = "cuda"
|
||||
```
|
||||
|
||||
Установка extra сама по себе не меняет `auto`: он всегда использует ONNX CPU.
|
||||
Явная CUDA при ошибке завершает обработку с исходной причиной; для распознанных
|
||||
причин выводится подсказка. Молчаливого перехода на CPU нет. В батче ошибка
|
||||
учитывается для каждого файла.
|
||||
|
||||
На Windows нужен также [Visual C++ Runtime x64](https://aka.ms/vs/17/release/vc_redist.x64.exe).
|
||||
Состав DLL проверен для закреплённых версий CTranslate2 и cuBLAS;
|
||||
реальный прогон нового пути на Windows без Toolkit пока не выполнен.
|
||||
Подробности и ограничения проверки — [ADR-001](docs/adr/001-cuda-bootstrap.md).
|
||||
|
||||
При обновлении из старой установки:
|
||||
|
||||
- Для CPU повторите обычную команду `uv tool install` с `--force` без `[cuda]`.
|
||||
В окружении разработки выполните `uv sync` — прежняя обязательная cuBLAS будет удалена.
|
||||
- Для CUDA повторите соответствующую команду выше. Старый автоматический выбор
|
||||
по `nvidia-smi` больше не действует; сохраните `device = "cuda"` в конфиге
|
||||
либо передавайте `--device cuda`.
|
||||
|
||||
## Использование
|
||||
|
||||
```bash
|
||||
# Простой запуск (CUDA medium или ONNX GigaAM RNN-T, язык ru)
|
||||
# Простой запуск (ONNX GigaAM RNN-T на CPU, язык ru)
|
||||
transcribe meeting.mp4
|
||||
|
||||
# Указать язык
|
||||
transcribe lecture.mp3 --language en
|
||||
transcribe lecture.mp3 --device cpu --model medium --language en
|
||||
|
||||
# Максимальное качество на NVIDIA GPU
|
||||
transcribe podcast.wav --model large-v3 --compute-type float16
|
||||
transcribe podcast.wav --device cuda --model large-v3 --compute-type float16
|
||||
|
||||
# Максимальное качество на Intel GPU
|
||||
transcribe podcast.wav --model large-v3 --device openvino-gpu
|
||||
@@ -218,15 +298,32 @@ transcribe --uninstall-menu
|
||||
| Опция | Сокращение | По умолчанию | Описание |
|
||||
|-------|-----------|-------------|----------|
|
||||
| `--model` | `-m` | medium (CUDA) / gigaam-v3-e2e-rnnt (ONNX) | Модель распознавания |
|
||||
| `--language` | `-l` | `ru` | Язык (ru, en, auto и др.); автоматический профиль без NVIDIA понимает только русский |
|
||||
| `--language` | `-l` | `ru` | Язык (ru, en, auto и др.); автоматический профиль понимает только русский |
|
||||
| `--output` | `-o` | `<файл>-transcript.md` | Путь к выходному файлу |
|
||||
| `--device` | `-d` | `auto` | Устройство (auto, cpu, cuda, openvino, openvino-gpu, openvino-cpu, onnx) |
|
||||
| `--compute-type` | — | float16 (CUDA) / int8 (ONNX/OpenVINO) / float32 (CPU) | Тип вычислений |
|
||||
| `--threads` | `-t` | 0 (авто) | Потоки CPU (рекомендуется = число физ. ядер) |
|
||||
| `--threads` | `-t` | 0 (авто) | Потоки CPU (рекомендуется = число физ. ядер); для ONNX задаёт потоки и ASR, и VAD |
|
||||
| `--diarize` / `--no-diarize` | — | `false` | Включить или отключить разделение на реплики говорящих |
|
||||
| `--speakers` | — | авто | Ожидаемое число говорящих; включает диаризацию и несовместим с `--no-diarize` |
|
||||
| `--force` | `-f` | — | Перезаписать существующие транскрипты |
|
||||
| `--verbose` | `-v` | — | Подробный вывод |
|
||||
| `--verbose` | `-v` | — | Подробный вывод: сегменты по ходу, движок, потоки, версии runtime |
|
||||
|
||||
### Диагностика исполнения
|
||||
|
||||
Перед распознаванием программа печатает модель, устройство и тип вычислений,
|
||||
которые выбраны фактически: после `auto` это `onnx`, после `openvino` —
|
||||
`openvino-gpu` или `openvino-cpu`. Если после загрузки устройство оказалось
|
||||
не тем, что выбрано до неё (например, OpenVINO взял CPU вместо GPU),
|
||||
выводится строка `Запрошено ..., используется ...`.
|
||||
|
||||
С `--verbose` добавляются движок, бюджет потоков и версии runtime: для ONNX —
|
||||
`onnxruntime`, `onnx-asr`, доступные providers и те, что заданы сессиям ASR
|
||||
и VAD; для CUDA — `ctranslate2` и число видимых CUDA-устройств; для OpenVINO —
|
||||
версия и доступные устройства. Этих строк достаточно, чтобы разобрать проблему
|
||||
на чужой машине без доступа к записи: попросите прислать вывод с `--verbose`.
|
||||
|
||||
Наличие provider в списке доступных не означает, что распознавание идёт
|
||||
на GPU или NPU: сессии получают только явно заданный список, сейчас это CPU.
|
||||
|
||||
## Платформы
|
||||
|
||||
@@ -235,7 +332,7 @@ transcribe --uninstall-menu
|
||||
| CPU через ONNX | ✅ авто | ✅ авто | ✅ авто |
|
||||
| OpenVINO (x86 CPU) | ✅ явно | — | ✅ явно |
|
||||
| OpenVINO (Intel GPU) | ✅ явно | — | ✅ явно |
|
||||
| GPU (NVIDIA) | ✅ авто | — | ✅ (нужен CUDA 12) |
|
||||
| GPU (NVIDIA) | extra + явно (x86_64) | — | extra + явно (x64; прогон без Toolkit ожидается) |
|
||||
|
||||
Данные по macOS основаны на доступности пакетов onnxruntime: прогонов на этой
|
||||
платформе не было.
|
||||
@@ -244,9 +341,9 @@ transcribe --uninstall-menu
|
||||
<summary>Linux / WSL2</summary>
|
||||
|
||||
- **Intel GPU** (Arc, встроенная графика) работает из коробки через OpenVINO
|
||||
- **NVIDIA GPU** работает из коробки — cuBLAS ставится автоматически как зависимость
|
||||
- Нужен только драйвер NVIDIA (проверка: `nvidia-smi`)
|
||||
- На ARM (aarch64) cuBLAS через pip недоступен — нужен системный CUDA toolkit
|
||||
- **NVIDIA GPU**: [extra `cuda` и явный выбор](#подключение-cuda), отдельно совместимый драйвер
|
||||
- `nvidia-smi` проверяет наличие драйвера, но не доказывает совместимость GPU с runtime
|
||||
- CUDA extra на ARM (aarch64) этим проектом не поддерживается
|
||||
|
||||
</details>
|
||||
|
||||
@@ -263,13 +360,9 @@ transcribe --uninstall-menu
|
||||
|
||||
- CPU работает из коробки
|
||||
- **Intel GPU** (Arc, встроенная графика) работает из коробки через OpenVINO
|
||||
- Для **NVIDIA GPU** нужен **CUDA 12** (ctranslate2 4.7 не совместим с CUDA 11 и 13):
|
||||
```
|
||||
winget install -e --id Nvidia.CUDA --version 12.9
|
||||
```
|
||||
> `winget install` требует запуска от имени администратора (elevated terminal).
|
||||
> `uv tool install` работает без админа (ставит в пользовательскую директорию).
|
||||
- После установки CUDA перезапустите терминал
|
||||
- **NVIDIA GPU**: [extra `cuda` и явный выбор](#подключение-cuda), отдельно совместимый драйвер
|
||||
- Требуется Visual C++ Runtime x64; системный CUDA Toolkit не нужен для extra
|
||||
- Реальная транскрипция с extra без Toolkit пока не проверена на Windows
|
||||
|
||||
</details>
|
||||
|
||||
@@ -293,8 +386,8 @@ diarize = true
|
||||
`--diarize` и `--no-diarize` позволяют переопределить `diarize` из конфига для
|
||||
отдельного запуска.
|
||||
|
||||
При `device = "auto"` выбирается CUDA, если доступен `nvidia-smi`, иначе ONNX.
|
||||
Явный `device` из CLI или конфига отключает этот автоматический выбор.
|
||||
При `device = "auto"` всегда выбирается ONNX CPU, даже если доступны
|
||||
`nvidia-smi` и extra `cuda`. Другие варианты выбираются явно из CLI или конфига.
|
||||
Каталоги моделей различаются между бэкендами, поэтому при закреплении `model`
|
||||
в конфиге рекомендуется явно закрепить и совместимый `device`. То же с языком:
|
||||
автоматический ONNX-профиль рассчитан на русскую речь, а для остальных языков
|
||||
@@ -311,9 +404,9 @@ diarize = true
|
||||
## Модели и GPU
|
||||
|
||||
Рекомендации:
|
||||
- **По умолчанию без CUDA:** ONNX `gigaam-v3-e2e-rnnt` — читаемый русский текст с
|
||||
- **По умолчанию:** ONNX `gigaam-v3-e2e-rnnt` — читаемый русский текст с
|
||||
пунктуацией почти без потери скорости относительно сырого `gigaam-v3`
|
||||
- **Макс. качество (NVIDIA):** `large-v3` + `--compute-type float16`
|
||||
- **Макс. качество (NVIDIA):** `--device cuda --model large-v3 --compute-type float16`
|
||||
- **Макс. качество (Intel GPU):** `large-v3` + `--device openvino-gpu`
|
||||
- **Макс. скорость CPU (русский):** `--device onnx --model gigaam-v3` (17-29× RTF, без пунктуации; рекомендуется LLM-нормализация терминов после)
|
||||
- **Быстрый OpenVINO с низким WER:** `--device openvino-cpu --model large-v3-turbo --compute-type int8` (7,2× RTFx на контрольном Intel CPU; пунктуация может быть слабой)
|
||||
|
||||
+6
-6
@@ -49,7 +49,7 @@ transcribe <путь_к_файлу> [опции]
|
||||
--output, -o Путь к выходному файлу
|
||||
По умолчанию: <input_stem>-transcript.md
|
||||
--device, -d Устройство (auto|cpu|cuda|onnx|openvino|openvino-gpu|openvino-cpu)
|
||||
По умолчанию: auto (CUDA при наличии, иначе ONNX CPU)
|
||||
По умолчанию: auto (всегда ONNX CPU)
|
||||
--compute-type Тип вычислений
|
||||
По умолчанию: float16 (CUDA) / int8 (ONNX)
|
||||
--verbose, -v Подробный вывод (прогресс сегментов)
|
||||
@@ -103,7 +103,7 @@ transcribe <путь_к_файлу> [опции]
|
||||
|---------------------------|---------------------------------------|
|
||||
| RTX 3060 + large-v3 | ~10-15x (1 час аудио ≈ 4-6 мин) |
|
||||
| RTX 4050 + large-v3 | ~12-18x (1 час аудио ≈ 3-5 мин) |
|
||||
| Quadro M3000M + large-v3 | ~3-5x (1 час аудио ≈ 12-20 мин) |
|
||||
| Quadro M3000M + CUDA | Не поддерживается текущей сборкой; см. [совместимость](gpu.md#совместимость-gpu) |
|
||||
| CPU + ONNX GigaAM RNN-T | ~10-14x (1 час аудио ≈ 4-6 мин) |
|
||||
| CPU + OpenVINO Turbo INT8 | ~7-10x (1 час аудио ≈ 6-9 мин) |
|
||||
| CPU (modern) + large-v3 | ~0.5-1x (1 час аудио ≈ 60-120 мин) |
|
||||
@@ -115,7 +115,7 @@ transcribe <путь_к_файлу> [опции]
|
||||
|-------------------|-------|--------------------------|----------------------------|-------------------------|
|
||||
| RTX 3060 | 6 GB | ✅ | ✅ (впритык) | int8 — безопасный выбор |
|
||||
| RTX 4050 | 6 GB | ✅ | ✅ (впритык) | int8 — безопасный выбор |
|
||||
| Quadro M3000M | 4 GB | ✅ | ⚠️ может OOM | int8 обязательно |
|
||||
| Quadro M3000M | 4 GB | ❌ | ❌ | ONNX CPU или OpenVINO |
|
||||
| Без GPU | — | ONNX GigaAM INT8 | — | auto выбирает ONNX |
|
||||
|
||||
Device-aware дефолты выбирают `float16` для CUDA и `int8` для ONNX/OpenVINO.
|
||||
@@ -123,7 +123,7 @@ Device-aware дефолты выбирают `float16` для CUDA и `int8` д
|
||||
### 4.2. Требования к окружению
|
||||
|
||||
- Python ≥ 3.13
|
||||
- Для GPU: Linux/WSL2 — cuBLAS из nvidia-cublas-cu12 (ставится автоматически через `uv sync`); Windows — системный CUDA toolkit (см. ADR-001)
|
||||
- Для NVIDIA GPU на Linux/WSL x86_64 и Windows x64: совместимый драйвер, extra `cuda` и явный выбор CUDA (см. ADR-001); Windows также требует Visual C++ Runtime x64
|
||||
- Дисковое пространство для моделей: зависит от выбранного backend и модели
|
||||
- Выходные файлы: UTF-8 (явная кодировка при записи)
|
||||
|
||||
@@ -177,9 +177,9 @@ local-transcriber/
|
||||
| Качество распознавания русского текста | Среднее | large-v3 хорошо справляется с ru; при проблемах — попробовать `--language ru` вместо auto |
|
||||
| Нет разделения по спикерам | Низкое | Осознанно выведено за скоуп MVP; добавление diarization (pyannote.audio) — возможное расширение |
|
||||
| Первый запуск: долгая загрузка модели | Низкое | Прогресс-бар при скачивании; модели кешируются в `~/.cache/huggingface/` |
|
||||
| CUDA несовместимость на Windows | Среднее | Автоматический fallback на CPU + предупреждение; в README — инструкция по CUDA |
|
||||
| CUDA несовместимость на Windows | Среднее | Явная CUDA сохраняет причину ошибки; пользователь выбирает CPU или исправляет окружение по README |
|
||||
| Большие файлы (>2 часов) | Среднее | Учитывать память выбранного backend; чанкование рассматривается отдельно |
|
||||
| OOM на GPU с 4 GB VRAM | Среднее | CUDA использует float16; при OOM — fallback на CPU с предупреждением или явный более лёгкий профиль |
|
||||
| OOM на GPU с 4 GB VRAM | Среднее | CUDA использует float16; при OOM явной CUDA — ошибка с подсказкой выбрать меньшую модель или CPU |
|
||||
| Файл без речи (тишина, музыка, шум) | Низкое | Создаётся транскрипт с шапкой метаданных и `*Речь не обнаружена.*` в теле + предупреждение в stderr |
|
||||
|
||||
## 7. Вне скоупа MVP
|
||||
|
||||
+108
-52
@@ -1,78 +1,134 @@
|
||||
# ADR-001: Preload libcublas из pip-пакета для GPU на Linux/WSL2
|
||||
# ADR-001: Необязательная CUDA и загрузка cuBLAS из окружения
|
||||
|
||||
**Статус**: Принято
|
||||
**Статус**: Принято; пересмотр обязательной cuBLAS от 2026-03-18
|
||||
**Дата**: 2026-03-18
|
||||
**Обновлено**: 2026-09-21, задача [#2](https://git.dementev.space/ddmitry/local-transcriber/issues/2)
|
||||
|
||||
## Контекст
|
||||
|
||||
ctranslate2 (backend faster-whisper) в runtime делает `dlopen("libcublas.so.12")`,
|
||||
но не бандлит эту библиотеку в свой wheel — ожидает её в системе.
|
||||
Без установленного CUDA toolkit `uv run transcribe --device cuda` падает с ошибкой.
|
||||
CTranslate2 загружает cuBLAS динамически и не поставляет её в своём wheel.
|
||||
Прежняя обязательная зависимость добавляла около 581 MB скачивания на Linux
|
||||
x86_64, даже если пользователь работал только через ONNX CPU.
|
||||
Наличие `nvidia-smi` доказывает наличие утилиты драйвера, но не совместимость GPU
|
||||
с runtime. Автоматический выбор CUDA мог приводить к ошибке на старом GPU.
|
||||
|
||||
Ключевые факты:
|
||||
- `libcuda.so.1` приходит от NVIDIA driver (всегда есть, если GPU есть)
|
||||
- `libcublas.so.12` отсутствует в wheel ctranslate2 на обеих платформах
|
||||
- **cuDNN не нужен** — в `libctranslate2.so` ноль символов cudnn (проверено `nm -D` и `strings`)
|
||||
- На Windows ctranslate2 делает `os.add_dll_directory` в своём `__init__.py`,
|
||||
но только для DLL внутри пакета; `cublas64_12.dll` тоже не бандлится
|
||||
Прежнее утверждение о недоступности Windows wheel `nvidia-cublas-cu12` было
|
||||
ошибочным: такой wheel есть и для закреплённой версии 12.9.1.4. Следовательно,
|
||||
системный CUDA Toolkit не должен быть обязательным способом подключения Windows.
|
||||
|
||||
## Решение
|
||||
|
||||
### nvidia-cublas-cu12 как pip-зависимость
|
||||
CUDA подключается через extra `cuda` на Linux/WSL x86_64 и Windows x64.
|
||||
Драйвер NVIDIA остаётся внешним требованием; Windows также требует Visual C++
|
||||
Runtime x64. Установка extra не включает CUDA: пользователь выбирает её через
|
||||
CLI или TOML. `auto` всегда выбирает ONNX CPU, ASR и VAD получают явный
|
||||
`CPUExecutionProvider`. Команды установки и обновления — в
|
||||
[README](../../README.md#подключение-cuda).
|
||||
|
||||
В `pyproject.toml`:
|
||||
```
|
||||
"nvidia-cublas-cu12>=12.4; sys_platform == 'linux' and platform_machine == 'x86_64'"
|
||||
```
|
||||
CTranslate2 4.8.1 и cuBLAS 12.9.1.4 закреплены в `pyproject.toml`, чтобы
|
||||
установка через `uv tool` и из клона использовала проверенное сочетание.
|
||||
В cuBLAS 12.9.2.10 появилась дополнительная зависимость NVRTC. Обновление
|
||||
версий требует повторной проверки библиотек и bootstrap; одного обновления
|
||||
lock-файла недостаточно.
|
||||
|
||||
Нижняя граница `>=12.4` — ctranslate2 собран с CUDA 12.4. Пакет доступен только
|
||||
для Linux x86_64 (на Windows и macOS не устанавливается по platform marker).
|
||||
### Linux: preload по полному пути
|
||||
|
||||
### ctypes.CDLL preload вместо LD_LIBRARY_PATH
|
||||
`_cuda_bootstrap.py` загружает `libcublas.so.12` через `ctypes.CDLL` с
|
||||
`RTLD_GLOBAL`. Линкер затем разрешает запрос CTranslate2 по soname.
|
||||
Изменение `LD_LIBRARY_PATH` внутри процесса не решает задачу: glibc кеширует
|
||||
пути поиска. Путь optional-пакета определяется через `nvidia.cublas.__path__`,
|
||||
что поддерживает namespace package без `__file__`.
|
||||
|
||||
`_cuda_bootstrap.py` загружает `libcublas.so.12` по полному пути через
|
||||
`ctypes.CDLL(path, mode=RTLD_GLOBAL)` **до** первого `import ctranslate2`.
|
||||
### Windows: регистрация каталога и preload DLL
|
||||
|
||||
Почему не `os.environ["LD_LIBRARY_PATH"]`: на Linux/glibc динамический линкер (`ld.so`)
|
||||
кеширует пути поиска при старте процесса и **не перечитывает** `LD_LIBRARY_PATH`
|
||||
из environ в рамках уже запущенного процесса.
|
||||
Bootstrap регистрирует `nvidia/cublas/bin` через `os.add_dll_directory`, затем
|
||||
загружает по абсолютным путям сначала `cublasLt64_12.dll`, затем
|
||||
`cublas64_12.dll`. Handles библиотек и регистрации каталога хранятся до
|
||||
завершения процесса. Повторный вызов не загружает их заново. Это обеспечивает
|
||||
доступность уже загруженных DLL для динамического загрузчика CTranslate2 и
|
||||
не требует изменения системного PATH.
|
||||
|
||||
Почему `RTLD_GLOBAL`: без этого флага символы cublas не видны другим `.so`,
|
||||
загруженным позже (в т.ч. `libctranslate2.so`).
|
||||
Bootstrap вызывается только при создании CUDA-модели, до локального импорта
|
||||
`WhisperModel`. Если CTranslate2 ранее импортирован для декодирования аудио,
|
||||
preload всё равно выполняется до создания CUDA-модели. CPU-путь bootstrap не
|
||||
вызывает. Отсутствие optional-пакета безопасно: системная cuBLAS всё ещё может
|
||||
обслужить явно выбранную CUDA. Ошибка загрузки найденной Windows DLL сохраняется.
|
||||
|
||||
Динамический линкер кеширует загруженные библиотеки по soname — когда ctranslate2
|
||||
потом вызовет `dlopen("libcublas.so.12")`, линкер вернёт уже загруженный handle.
|
||||
### Ошибки и явный выбор
|
||||
|
||||
### strict_device для явного --device
|
||||
Явный `device` из CLI или TOML передаёт `strict_device=True` при загрузке и
|
||||
транскрипции. CUDA не заменяется молча на CPU. Подсказки различают отсутствие
|
||||
библиотек, проблемы драйвера, несовместимость GPU/runtime и нехватку памяти;
|
||||
отказ по неподдерживаемому типу вычислений учитывает устройство, чтобы
|
||||
одинаковая ошибка CTranslate2 на CPU не получала CUDA-подсказку. Исходное
|
||||
сообщение сохраняется. Неизвестная ошибка не получает произвольный
|
||||
совет установить Toolkit. Это одинаково для одного файла и батча.
|
||||
|
||||
`--device cuda` / `--device cpu` → `strict_device=True` → CUDA-ошибка = raise, без fallback.
|
||||
`--device auto` → `strict_device=False` → текущее поведение с fallback на CPU.
|
||||
Прежний механизм fallback оркестратора при `strict_device=False` сохранён
|
||||
для существующих программных callers. Автоматический CLI-путь теперь ONNX,
|
||||
поэтому он не попадает в CUDA fallback.
|
||||
|
||||
Мотивация: silent fallback для часового файла = 60 минут вместо 5.
|
||||
## Проверка зависимостей Windows
|
||||
|
||||
## Последствия и tradeoffs
|
||||
Статическая проверка 2026-09-21: скачаны wheels из PyPI, проверены SHA-256,
|
||||
для DLL/PYD прочитаны PE imports через `objdump -p` и строки динамической
|
||||
загрузки. Модели распознавания не скачивались.
|
||||
|
||||
**~400 MB на CPU-only Linux x86_64**: nvidia-cublas-cu12 ставится на все Linux x86_64,
|
||||
включая машины без GPU. Bootstrap при этом preload'ит libcublas (overhead ~1 ms),
|
||||
но она не используется, т.к. ctranslate2 не получит запрос на CUDA device.
|
||||
- `ctranslate2-4.8.1-cp313-cp313-win_amd64.whl`, SHA-256
|
||||
`d52499f05a60a791aeadee28d609efa130142f376d1ea76b2b1c593bb01f8827`.
|
||||
`ctranslate2.dll` содержит имена `cublas64_12.dll` и `nvcuda.dll`;
|
||||
ссылок на cuDNN и динамическую cudart в этой DLL не обнаружено.
|
||||
В wheel есть `cudnn64_9.dll`, но её наличие не означает использование
|
||||
cuDNN проверенной рабочей DLL. OpenMP DLL поставляется внутри wheel;
|
||||
импорты MSVCP/VCRUNTIME требуют Visual C++ Runtime.
|
||||
- `nvidia_cublas_cu12-12.9.1.4-py3-none-win_amd64.whl`, SHA-256
|
||||
`1e5fee10662e6e52bd71dec533fbbd4971bb70a5f24f3bc3793e5c2e9dc640bf`.
|
||||
DLL находятся в `nvidia/cublas/bin`. cuBLAS импортирует cuBLAS Lt и
|
||||
KERNEL32; cuBLAS Lt импортирует KERNEL32. В строках cuBLAS Lt также найдена
|
||||
динамическая ссылка на `nvrtc64_120_0.dll`, которой нет в PE imports.
|
||||
Обязательность NVRTC для Whisper этим статическим анализом не установлена;
|
||||
первый реальный Windows-прогон должен проверить и этот путь загрузки.
|
||||
`nvblas64_12.dll` не требуется проверенному пути CTranslate2.
|
||||
|
||||
Альтернатива — `[project.optional-dependencies]` + `uv sync --extra cuda`,
|
||||
но тогда теряется zero-config UX. Для v1 оставляем как обязательную зависимость.
|
||||
Основания: [CTranslate2 4.8.1 на PyPI](https://pypi.org/project/ctranslate2/4.8.1/#files),
|
||||
[cuBLAS 12.9.1.4 на PyPI](https://pypi.org/project/nvidia-cublas-cu12/12.9.1.4/#files),
|
||||
[загрузчик CTranslate2](https://github.com/OpenNMT/CTranslate2/blob/v4.8.1/python/ctranslate2/__init__.py),
|
||||
[метаданные cuBLAS 12.9.2.10](https://pypi.org/pypi/nvidia-cublas-cu12/12.9.2.10/json).
|
||||
|
||||
**Windows GPU**: nvidia-cublas-cu12 недоступен как pip-пакет для Windows.
|
||||
Единственный путь — системный CUDA toolkit (`choco install cuda` / `winget install -e --id Nvidia.CUDA`).
|
||||
CLI выводит эту подсказку при CUDA-ошибке на `sys.platform == "win32"`.
|
||||
Это обоснование состава extra для конкретных wheels, а не подтверждение
|
||||
транскрипции на Windows GPU. Реальный прогон Windows x64 с драйвером и extra,
|
||||
без Toolkit и ручной правки PATH, **не выполнен**. Linux-тесты с подменой
|
||||
Windows API не заменяют такую проверку.
|
||||
|
||||
**Namespace package**: `nvidia.cublas` — namespace package (`__file__` is `None`),
|
||||
для определения директории используется `__path__[0]`, а не `__file__`.
|
||||
## Проверка поставки и поведения
|
||||
|
||||
## Отклонённые альтернативы
|
||||
На Linux выполнены `uv run pytest`: 524 passed, 2 skipped. Пропуски —
|
||||
Windows-only кодировка OEM и интеграционный bootstrap без optional-пакета.
|
||||
До удаления cuBLAS интеграционный тест Linux bootstrap проходил.
|
||||
Обычный `uv sync --locked` удаляет прежнюю обязательную cuBLAS;
|
||||
`uv sync --extra cuda --locked --dry-run` возвращает её в план установки.
|
||||
|
||||
| Альтернатива | Почему отклонена |
|
||||
|---|---|
|
||||
| `nvidia-cudnn-cu12` в зависимостях | ctranslate2 не использует cuDNN — ноль символов, проверено через `nm -D` |
|
||||
| Self-reexec с `LD_LIBRARY_PATH` | ctypes.CDLL решает задачу без перезапуска процесса; self-reexec создаёт проблемы с сигналами, tty, fd |
|
||||
| `os.environ["LD_LIBRARY_PATH"] += ...` | glibc кеширует пути при старте, не перечитывает environ |
|
||||
| `DeviceResolution` dataclass + `resolve_device()` | Один `bool strict_device` решает ту же задачу проще |
|
||||
| ctranslate2 preflight (`get_supported_compute_types`) | Латентность; try/catch при загрузке модели не хуже |
|
||||
| Bootstrap console_scripts entrypoint | Не нужен — достаточно вызова в начале `transcriber.py` |
|
||||
Метаданные собранного wheel проверены для 12 сочетаний платформы, архитектуры
|
||||
и extra. Полное разрешение зависимостей wheel через `uv pip compile` для Linux
|
||||
и Windows, с extra и без, подтверждает отсутствие NVIDIA-пакетов в обычной
|
||||
установке и наличие только cuBLAS 12.9.1.4 при выборе extra.
|
||||
|
||||
Формы `uv tool install` для клона, клона с extra и именованной Git-зависимости
|
||||
с extra проверены в отдельном временном каталоге tools. Для проверки самой
|
||||
команды зависимости были исключены; их разрешение проверено отдельно выше.
|
||||
Git-транспорт проверялся через локальный репозиторий и ветку реализации,
|
||||
а не через ещё не опубликованную ветку на сервере. Версия uv — 0.12.17.
|
||||
|
||||
## Последствия и альтернативы
|
||||
|
||||
- Обычная установка не требует NVIDIA-пакетов. FasterWhisper CPU сохраняется.
|
||||
- Дополнительные библиотеки скачиваются только по выбору пользователя.
|
||||
- Единый extra заменяет требование установить полный Toolkit на Windows;
|
||||
существующая системная установка библиотек остаётся допустимой.
|
||||
- Отклонено сохранение обязательной cuBLAS ради готовности CUDA сразу после
|
||||
установки: это увеличивает CPU-установку и не решает несовместимость GPU.
|
||||
- Отклонено автоматическое включение CUDA по драйверу или extra: установка
|
||||
библиотек не должна менять движок и модель по умолчанию.
|
||||
- Перезапуск процесса с `LD_LIBRARY_PATH` не нужен; явная загрузка библиотек
|
||||
решает проблему без изменения обработки сигналов и файловых дескрипторов.
|
||||
- CUDA extra на Windows ARM64, Linux ARM и macOS в эту задачу не входит.
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
**Статус**: Принято
|
||||
**Дата**: 2026-03-18
|
||||
**Обновлено**: 2026-09-21 — решения 2 и 4 пересмотрены (см. пометки)
|
||||
|
||||
## Контекст
|
||||
|
||||
@@ -17,19 +18,23 @@
|
||||
|
||||
**Почему**: загрузка модели (large-v3) занимает ~10 секунд и ~3 GB RAM/VRAM. Повторный запуск по уже обработанным файлам должен быть дешёвым no-op.
|
||||
|
||||
### 2. TranscribeFileResult: возврат обновлённого состояния модели
|
||||
### 2. Состояние модели между файлами
|
||||
|
||||
При mid-stream CUDA fallback `_transcribe_file()` перезагружает модель на CPU внутри себя. Чтобы следующие файлы в батче не грузили модель повторно, результат включает обновлённые `model` и `actual_device`.
|
||||
**Пересмотрено 2026-09-21.** Исходное решение: `_transcribe_file()` возвращал
|
||||
`TranscribeFileResult` с обновлёнными `model`, `backend`, `model_path`
|
||||
и `actual_device`, а CLI переносил их в следующий файл после mid-stream
|
||||
fallback. Это делало caller владельцем согласованности четырёх полей.
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class TranscribeFileResult:
|
||||
result: TranscribeResult
|
||||
model: WhisperModel # может измениться при fallback
|
||||
actual_device: str # может измениться при fallback
|
||||
```
|
||||
Теперь состояние не покидает module выполнения: `Transcriber` создаётся один
|
||||
раз на запуск после prescan, загружает модель при первом файле и сам держит
|
||||
загруженную модель, adapter и фактическое исполнение. Fallback меняет
|
||||
состояние внутри module, и следующий файл батча идёт уже по нему. Caller
|
||||
получает `TranscribeResult` и `ExecutionInfo` (запрошенное и выбранное
|
||||
исполнение, движок, модель, квантизация, потоки, версии runtime).
|
||||
|
||||
**Альтернатива**: передавать модель по ссылке через mutable контейнер — менее явно и сложнее тестировать.
|
||||
**Альтернатива**: mutable-контейнер с моделью, передаваемый по ссылке, —
|
||||
отклонена тогда же; owner-объект решает ту же задачу явно и тестируется
|
||||
через свой interface.
|
||||
|
||||
### 3. Конфиг: CWD → глобальный, без мержа
|
||||
|
||||
@@ -43,15 +48,18 @@ class TranscribeFileResult:
|
||||
|
||||
**Приоритет значений**: CLI > конфиг > хардкод.
|
||||
|
||||
### 4. `_transcribe_file()` — внутренний helper
|
||||
### 4. Публичный `transcribe()` — путь одного файла
|
||||
|
||||
Публичный API (`transcribe()`) сохранён без изменений. Новая функция `_transcribe_file()` — внутренний helper с префиксом `_`, не часть публичного контракта.
|
||||
|
||||
`transcribe()` стала тонкой обёрткой: `load_model()` + `_transcribe_file()` → `TranscribeResult`.
|
||||
**Пересмотрено 2026-09-21.** Внутренние helpers `load_model()`
|
||||
и `_transcribe_file()` удалены. `transcribe()` сохранён и стал обёрткой над
|
||||
тем же `Transcriber`: `model_name` и `compute_type` без значения берут
|
||||
умолчания выбранного исполнения, `device="auto"` означает ONNX CPU, как в CLI.
|
||||
|
||||
## Последствия
|
||||
|
||||
- Обратная совместимость CLI: `transcribe file.mp4` работает как раньше
|
||||
- Все существующие тесты проходят без изменений сигнатур
|
||||
- Batch mode: модель загружается один раз для всех файлов
|
||||
- Batch mode: модель загружается один раз для всех файлов; CLI не переносит
|
||||
состояние между файлами
|
||||
- Fallback из CLI недостижим: явный device — strict, `auto` — ONNX CPU без
|
||||
переходов. Механизм сохранён для `transcribe(..., strict_device=False)`
|
||||
- `--force` флаг для перезаписи существующих транскриптов в батч-режиме
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Статус**: Принято
|
||||
**Дата**: 2026-03-21
|
||||
**Обновлено**: 2026-08-12
|
||||
**Обновлено**: 2026-09-21
|
||||
|
||||
## Контекст
|
||||
|
||||
@@ -32,8 +32,9 @@ Protocol вместо ABC — бэкенды не наследуются, дос
|
||||
### Ленивые импорты
|
||||
|
||||
Бэкенды импортируются только при выборе — `get_backend(device)` делает import внутри.
|
||||
Импорт faster-whisper запускает CUDA bootstrap (~1ms), импорт openvino-genai загружает ~50MB
|
||||
shared libraries. Ни то, ни другое не должно происходить, если бэкенд не выбран.
|
||||
CUDA bootstrap вызывается только при создании CUDA-модели (см. ADR-001).
|
||||
Импорт openvino-genai загружает ~50MB shared libraries и не должен происходить,
|
||||
если бэкенд не выбран.
|
||||
|
||||
### Device как селектор бэкенда
|
||||
|
||||
@@ -41,23 +42,41 @@ shared libraries. Ни то, ни другое не должно происхо
|
||||
- `cuda`, `cpu` → FasterWhisperBackend
|
||||
- `openvino` → OpenVINOBackend
|
||||
- `onnx` → OnnxAsrBackend
|
||||
- `auto` → CUDA при наличии `nvidia-smi`, иначе ONNX на CPU
|
||||
- `auto` → ONNX на CPU независимо от `nvidia-smi` и установки extra
|
||||
|
||||
### load_model() — единственный владелец pipeline
|
||||
### Transcriber — единственный владелец загрузки и исполнения
|
||||
|
||||
`load_model()` выполняет ensure_model_available + create_model в одном вызове.
|
||||
CLI не вызывает ensure_model_available отдельно — это убирает двойной resolution
|
||||
и гарантирует, что модель скачивается для правильного бэкенда.
|
||||
Обновлено 2026-09-21. `Transcriber(ExecutionRequest)` в `transcriber.py`
|
||||
разрешает `auto` и device-aware умолчания, выполняет ensure_model_available +
|
||||
create_model в одном месте и держит модель, adapter и фактическое исполнение
|
||||
на весь запуск. CLI не вызывает `get_backend` и не хранит handles.
|
||||
|
||||
Внутри module различаются три вещи: поддерживаемая модель, движок
|
||||
распознавания (`faster-whisper`, `openvino`, `onnx-asr`) и способ исполнения.
|
||||
Значение `device` из CLI/TOML сохраняется как вход: `get_backend` выбирает
|
||||
по нему adapter, adapter называет свой движок атрибутом `engine`, а
|
||||
аппаратную часть (CPU, CUDA, Intel GPU, список providers) трактует сам.
|
||||
Новое исполнение — новое значение `device` в `get_backend` и его понимание
|
||||
в adapter'е, не новая иерархия adapters.
|
||||
|
||||
### Cross-backend fallback
|
||||
|
||||
Fallback живёт в `transcriber.py` (оркестратор), не в бэкендах:
|
||||
Fallback живёт внутри `Transcriber`, не в бэкендах и не в CLI:
|
||||
- CUDA ошибка → CPU (FasterWhisper)
|
||||
- OpenVINO ошибка → CPU (FasterWhisper)
|
||||
- `strict_device=True` (явный `--device`) → ошибка без fallback
|
||||
- strict запуск (явный device из CLI или TOML) → ошибка без fallback
|
||||
|
||||
При fallback в батч-режиме обновляются model, backend, model_path и actual_device
|
||||
через TranscribeFileResult — следующий файл использует правильный бэкенд.
|
||||
После перехода module сам держит новое состояние; следующий файл батча
|
||||
использует его без участия caller. `WordTimestampsUnavailableError`
|
||||
и ошибки пользовательских данных переходом не считаются.
|
||||
|
||||
### Диагностика исполнения
|
||||
|
||||
Каждый adapter отдаёт `runtime_info()` — версии runtime и фактическую
|
||||
конфигурацию (для ONNX — доступные providers и те, что заданы сессиям ASR
|
||||
и VAD, квантизация, бюджет потоков). `Transcriber` включает их в
|
||||
`ExecutionInfo`; CLI печатает их в `--verbose`. Наличие provider в wheel не
|
||||
считается доказательством выполнения графа на GPU/NPU.
|
||||
|
||||
### Аудио для OpenVINO
|
||||
|
||||
@@ -76,13 +95,13 @@ OpenVINO модели предквантизированы (int8/fp16), compute_
|
||||
### Зависимости бэкендов по умолчанию
|
||||
|
||||
faster-whisper, onnx-asr/onnxruntime и openvino-genai ставятся вместе. Модели
|
||||
скачиваются только для активного бэкенда. CUDA (`nvidia-cublas-cu12`) остаётся
|
||||
conditional для Linux x86_64. OpenVINO — conditional для x86_64/AMD64, кроме
|
||||
скачиваются только для активного бэкенда. cuBLAS подключается через extra `cuda`
|
||||
для Linux/WSL x86_64 и Windows x64 (см. ADR-001). OpenVINO — conditional для x86_64/AMD64, кроме
|
||||
macOS; ONNX обеспечивает автоматический CPU-путь на остальных платформах.
|
||||
|
||||
## Последствия
|
||||
|
||||
- Обратная совместимость: `transcribe()` сохранён; `load_model()` изменил сигнатуру (возвращает 4-tuple вместо 2-tuple, добавлен `compute_type_explicit`)
|
||||
- Обратная совместимость: `transcribe()` сохранён; `load_model()` и `_transcribe_file()` удалены (2026-09-21)
|
||||
- Новый бэкенд добавляется одним файлом в `backends/` + регистрацией в `__init__.py`
|
||||
- Модели скачиваются по запросу — CUDA пользователь не качает OpenVINO модели, и наоборот
|
||||
- ARM и macOS: OpenVINO не ставится (platform markers), auto использует ONNX
|
||||
|
||||
@@ -102,10 +102,12 @@ Mm-hmm-редукция и иностранные вставки **не обна
|
||||
## Решение
|
||||
|
||||
**Принять onnx-asr как CPU-бэкенд автоматического профиля.
|
||||
GigaAM v3 E2E RNN-T — модель по умолчанию на машинах без CUDA.**
|
||||
GigaAM v3 E2E RNN-T — модель автоматического профиля на всех машинах.**
|
||||
|
||||
Порядок `--device auto`: CUDA при наличии `nvidia-smi`, иначе ONNX. OpenVINO и
|
||||
FasterWhisper CPU остаются доступными через явный CLI-аргумент или конфиг.
|
||||
С 2026-09-21 `--device auto` всегда выбирает ONNX CPU; для ASR и VAD явно
|
||||
передаётся `CPUExecutionProvider`, независимо от доступных providers. CUDA,
|
||||
OpenVINO и FasterWhisper CPU доступны через явный CLI-аргумент или конфиг.
|
||||
Подключение CUDA описано в [ADR-001](001-cuda-bootstrap.md).
|
||||
|
||||
GigaAM понимает только русскую речь, поэтому для остальных языков автоматический
|
||||
профиль не годится — нужен явный Whisper. Несовместимый язык работу не
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
- Основной remote: `origin`
|
||||
- Gitea: `https://git.dementev.space`
|
||||
- CLI: `tea`
|
||||
- Remote `github` является зеркалом и не используется для управления задачами
|
||||
- Внешние pull request не входят в очередь triage
|
||||
|
||||
## Доступ
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# Бенчмарк: качество и скорость транскрипции
|
||||
|
||||
**Дата**: 2026-03-23
|
||||
|
||||
**Статус:** архив. Перенесено из ветки `feature/amd-acceleration`; конфигурации бэкендов и умолчания с тех пор изменились (ONNX CPU по умолчанию, см. ADR-003).
|
||||
|
||||
## Цель
|
||||
|
||||
Сравнить качество распознавания нашего приложения (local-transcriber) с облачным
|
||||
сервисом Whisper.ai, подобрать оптимальную конфигурацию бэкенд/модель/compute_type.
|
||||
|
||||
## Тестовый стенд
|
||||
|
||||
- **CPU**: AMD Ryzen 7 8845H w/ Radeon 780M (3.80 GHz), 8 ядер
|
||||
- **GPU**: нет NVIDIA (нет CUDA), есть встроенный Radeon 780M iGPU + Ryzen AI NPU (XDNA)
|
||||
- **ОС**: Windows 11 Enterprise
|
||||
- **Запись**: совещание 3 участников, 14:41, русский язык, микрофон ноутбука
|
||||
|
||||
## Конфигурации
|
||||
|
||||
| # | Модель | Бэкенд | compute_type | Устройство |
|
||||
|---|--------|--------|--------------|------------|
|
||||
| 1 | medium | OpenVINO | fp16 | CPU |
|
||||
| 2 | large-v3 | OpenVINO | fp16 | CPU |
|
||||
| 3 | medium | CTranslate2 (faster-whisper) | float32 | CPU |
|
||||
| 4 | medium | CTranslate2 (faster-whisper) | int8_float32 | CPU |
|
||||
| 5 | — | Whisper.ai (облако) | — | — |
|
||||
|
||||
## Результаты: скорость
|
||||
|
||||
| # | Конфигурация | Время, с | Коэфф. к реалтайму (14:41 = 881с) |
|
||||
|---|---|---|---|
|
||||
| 1 | medium, OpenVINO fp16 | **~240** | 0.27x |
|
||||
| 2 | large-v3, OpenVINO fp16 | ~503 | 0.57x |
|
||||
| 4 | medium, CT2 int8_float32 | ~599 | 0.68x |
|
||||
| 3 | medium, CT2 float32 | ~881 | 1.0x |
|
||||
|
||||
## Результаты: качество
|
||||
|
||||
Для оценки выбраны маркерные фразы — технические термины, сложная разговорная речь,
|
||||
имена собственные. Каждый маркер проверен вручную по контексту записи.
|
||||
|
||||
### Техтермины (английские в русской речи)
|
||||
|
||||
| Термин | #1 OV fp16 | #2 large OV | #3 CT2 f32 | #4 CT2 int8f32 | #5 Whisper.ai |
|
||||
|--------|-----------|-------------|------------|----------------|---------------|
|
||||
| DWH | ✅ DWH | ❌ ДВХ | ⚠️ ДВХ | ✅ DWH | ⚠️ dvh |
|
||||
| Teradata | ✅ TeraData | ⚠️ тирадат | ⚠️ Тирадату | ✅ Teradata | ⚠️ тирадата |
|
||||
| NiFi | ✅ NiFi | ❌ "на файл" | ⚠️ Найфай | ✅ NiFi | ⚠️ фай |
|
||||
| FineBI | ✅ fineBI | ❌ "фанбой" | ✅ FNBI | ✅ finebi/FNBI | ⚠️ FineBI/NBI |
|
||||
| Alation | ❌ "лэйшене" | ❌ "валаши" | ✅ Allation | ✅ Allation | ✅ Allation |
|
||||
| ETL | ❌ ETA | ❌ "и ты или" | ✅ ETL | ✅ ETL | ✅ ETL |
|
||||
| DBC Tables V | ✅ | ❌ "деби си" | ✅ | ✅ | ✅ |
|
||||
| OpenLineage | ⚠️ OpenLinage | ⚠️ "open lineage и о" | ✅ OpenLinage.io | ✅ OpenLinage.io | ✅ Open Lineage.io |
|
||||
|
||||
### Русская разговорная речь
|
||||
|
||||
| Фраза (правильно) | #1 OV fp16 | #2 large OV | #3 CT2 f32 | #4 CT2 int8f32 | #5 Whisper.ai |
|
||||
|--------------------|-----------|-------------|------------|----------------|---------------|
|
||||
| "на другую мониторочку" | ❌ "другом не торчка" | ❌ "другому и" | ✅ "мониторчику" | ⚠️ "мою точку" | ✅ "мониторочку" |
|
||||
| "верхнеуровневое" | ⚠️ "верхверх верхне..." | ✅ | ✅ | ✅ | ✅ |
|
||||
| "датарентген" | ❌ "госпиталин ген" | ❌ "господа тренды" | ✅ "даторентген" | ✅ "даторентген" | ✅ "датарентген" |
|
||||
| "девовские схемы" | ✅ | ❌ "девушки схемы" | ✅ | ✅ | ✅ |
|
||||
| "с накиданными правами" | ✅ | ❌ "накиданных бровами" | ✅ | ✅ | ✅ |
|
||||
| "Вьюхи" | ❌ "в юхе" | — | ✅ "Вьюхи" | ⚠️ "Yuhi" | ❌ "в юхе" |
|
||||
|
||||
### Структура и пунктуация
|
||||
|
||||
| Аспект | #1 OV fp16 | #2 large OV | #3 CT2 f32 | #4 CT2 int8f32 | #5 Whisper.ai |
|
||||
|--------|-----------|-------------|------------|----------------|---------------|
|
||||
| Пунктуация | слабая | слабая | хорошая | хорошая | средняя |
|
||||
| Длина сегментов | ~1 мин | ~1 мин | ~1 мин | ~1 мин | 2–30 сек |
|
||||
| Диаризация | нет | нет | нет | нет | есть (условная) |
|
||||
|
||||
## Ключевые выводы
|
||||
|
||||
### 1. OpenVINO fp16 ухудшает качество medium
|
||||
|
||||
OpenVINO даёт **3.7x ускорение**, но fp16-квантизация на CPU вносит заметные ошибки
|
||||
в распознавание нечёткой русской речи ("госпиталин ген", "ETA"). При этом английские
|
||||
техтермины (DWH, NiFi, FineBI) распознаются хорошо — видимо, они более «чёткие»
|
||||
акустически.
|
||||
|
||||
### 2. large-v3 через OpenVINO — хуже medium
|
||||
|
||||
Удивительный результат: large-v3 с OpenVINO fp16 деградировал по всем параметрам.
|
||||
FineBI → "фанбой", NiFi → "на файл", девовские → "девушки". Гипотеза: fp16 на CPU
|
||||
бьёт сильнее по большим моделям из-за накопления ошибок округления.
|
||||
|
||||
### 3. CTranslate2 float32 — лучшее качество
|
||||
|
||||
Сравнимо с облачным Whisper.ai, а на некоторых маркерах лучше (Вьюхи, DWH).
|
||||
Но **3.7x медленнее** OpenVINO — неприемлемо для длинных записей.
|
||||
|
||||
### 4. int8_float32 — оптимальный баланс
|
||||
|
||||
Mixed precision (int8 матрицы, float32 аккумулятор) даёт:
|
||||
- **1.5x ускорение** vs float32 (599с vs 881с)
|
||||
- Качество практически идентичное float32
|
||||
- Техтермины DWH/Teradata/NiFi даже лучше, чем у float32
|
||||
|
||||
### 5. Whisper.ai — диаризация как преимущество
|
||||
|
||||
Единственное значимое преимущество облачного сервиса — диаризация спикеров.
|
||||
По качеству распознавания наше приложение с CT2 int8_float32 на уровне или лучше.
|
||||
|
||||
## Рекомендации
|
||||
|
||||
### Краткосрочно
|
||||
|
||||
- **Дефолт для CPU без CUDA**: рассмотреть `int8_float32` вместо `float32` — 1.5x быстрее
|
||||
при сопоставимом качестве.
|
||||
- **OpenVINO fp16 оставить** как быстрый вариант — для случаев, когда скорость важнее
|
||||
точности (превью, черновые транскрипты).
|
||||
|
||||
### Среднесрочно
|
||||
|
||||
- **DirectML бэкенд** для AMD Radeon iGPU — через onnxruntime-directml можно задействовать
|
||||
Radeon 780M. Float16 на GPU (в отличие от CPU-квантизации) не должен терять качество.
|
||||
Потенциально 3–5x ускорение при сохранении качества float32.
|
||||
|
||||
### Долгосрочно
|
||||
|
||||
- **Ryzen AI NPU (XDNA)** — AMD развивает поддержку, но tooling пока сырой.
|
||||
- **Диаризация** — добавить идентификацию спикеров (pyannote-audio или аналоги).
|
||||
+25
-26
@@ -2,7 +2,7 @@
|
||||
|
||||
## Режимы `--device`
|
||||
|
||||
- `auto` (по умолчанию) — CUDA при наличии `nvidia-smi`, иначе ONNX на CPU
|
||||
- `auto` (по умолчанию) — всегда ONNX на CPU
|
||||
- `cuda` — строго NVIDIA GPU, ошибка если недоступен
|
||||
- `openvino` — авто-выбор OpenVINO GPU или CPU
|
||||
- `openvino-gpu` — строго Intel GPU через OpenVINO
|
||||
@@ -13,8 +13,8 @@
|
||||
|
||||
| Оборудование | Рекомендуемый `--device` | Бэкенд | Ожидаемая скорость |
|
||||
|---|---|---|---|
|
||||
| NVIDIA GPU (6+ GB VRAM) | `auto` / `cuda` | faster-whisper (CTranslate2) | 7-19x реалтайм |
|
||||
| Любой CPU без NVIDIA | `auto` / `onnx` | ONNX GigaAM RNN-T | 10-14x реалтайм* |
|
||||
| NVIDIA GPU (6+ GB VRAM) | `cuda` + extra | faster-whisper (CTranslate2) | 7-19x реалтайм |
|
||||
| Любой CPU | `auto` / `onnx` | ONNX GigaAM RNN-T | 10-14x реалтайм* |
|
||||
| Intel Arc iGPU / dGPU | `openvino-gpu` | OpenVINO GenAI (GPU) | TBD |
|
||||
| Intel/AMD x86 CPU | `openvino-cpu` | OpenVINO GenAI (CPU) | 3-10x реалтайм* |
|
||||
| Любой CPU, FasterWhisper | `cpu` | faster-whisper (CTranslate2) | ~1.5x реалтайм |
|
||||
@@ -22,7 +22,7 @@
|
||||
\* По результатам контрольных прогонов на Intel и AMD CPU. Реальная скорость
|
||||
зависит от CPU, модели и записи.
|
||||
|
||||
Автоматический профиль без NVIDIA рассчитан на русскую речь: GigaAM других
|
||||
Автоматический профиль рассчитан на русскую речь: GigaAM других
|
||||
языков не понимает. Для них берите Whisper — `openvino-cpu` на x86 или `cpu`
|
||||
на любой платформе.
|
||||
|
||||
@@ -211,22 +211,16 @@ SMT/Hyper-Threading не помогает — 16 потоков на 8-ядер
|
||||
|
||||
## Настройка по платформам
|
||||
|
||||
### Linux / WSL2 (x86_64)
|
||||
Для Linux/WSL x86_64 и Windows x64 используется один extra `cuda` и явный
|
||||
выбор `--device cuda` либо `device = "cuda"` в TOML. Команды установки из Git,
|
||||
клона и для разработки приведены в [README](../README.md#подключение-cuda).
|
||||
Обычный запуск всегда использует ONNX CPU, независимо от драйвера и extra.
|
||||
|
||||
Библиотека cuBLAS ставится автоматически (зависимость `nvidia-cublas-cu12` подтягивается при установке).
|
||||
Дополнительных шагов не требуется.
|
||||
|
||||
> На ARM (aarch64) cuBLAS через pip недоступен — нужен системный CUDA toolkit.
|
||||
|
||||
### Windows
|
||||
|
||||
Нужен системный **CUDA 12** (ctranslate2 4.7 не совместим с CUDA 11 и 13):
|
||||
|
||||
```bash
|
||||
winget install -e --id Nvidia.CUDA --version 12.9 # требует запуска от имени администратора
|
||||
```
|
||||
|
||||
После установки перезапустите терминал.
|
||||
Драйвер NVIDIA устанавливается отдельно. Полный системный CUDA Toolkit не
|
||||
нужен для пути через extra; на Windows требуется Visual C++ Runtime x64.
|
||||
Состав Windows DLL проверен статически; реальная транскрипция без Toolkit
|
||||
пока не проверена. Версии и границы проверки — [ADR-001](adr/001-cuda-bootstrap.md).
|
||||
CUDA extra для ARM в этой задаче не поддерживается.
|
||||
|
||||
## Совместимость GPU
|
||||
|
||||
@@ -331,14 +325,19 @@ threshold не помогла — проблема inherent для модели
|
||||
Убедитесь, что `nvidia-smi` возвращает информацию о GPU. Драйвер NVIDIA предоставляет `libcuda.so.1`,
|
||||
без которого CUDA не работает — его нельзя поставить через pip.
|
||||
|
||||
### Windows: ошибка при загрузке модели на GPU
|
||||
### Отсутствующие библиотеки и несовместимый GPU
|
||||
|
||||
GPU на Windows требует **CUDA 12** (ctranslate2 4.7 не совместим с CUDA 11 и 13). Установите:
|
||||
```bash
|
||||
winget install -e --id Nvidia.CUDA --version 12.9 # требует запуска от имени администратора
|
||||
```
|
||||
После установки перезапустите терминал.
|
||||
При отсутствии cuBLAS подключите extra в том окружении, откуда запускается
|
||||
программа, по [инструкции README](../README.md#подключение-cuda).
|
||||
На Windows ошибка загрузки DLL может также означать отсутствие Visual C++ Runtime.
|
||||
|
||||
`no kernel image` / `invalid device function` означают несовместимость GPU
|
||||
с выбранной сборкой runtime. Установка extra её не исправит. Ошибки драйвера
|
||||
требуют проверки драйвера; нехватка VRAM — меньшей модели или явного выбора CPU.
|
||||
Исходная ошибка сохраняется. CLI/TOML с явной CUDA не переходят молча на CPU
|
||||
ни при загрузке, ни при распознавании.
|
||||
|
||||
### ARM (aarch64)
|
||||
|
||||
cuBLAS через pip недоступен на ARM — нужен системный CUDA toolkit.
|
||||
CUDA extra не поддерживается проектом на этой архитектуре. Наличие отдельных
|
||||
NVIDIA wheels для ARM само по себе не подтверждает рабочий путь транскрипции.
|
||||
@@ -0,0 +1,353 @@
|
||||
# Исследование: ускорение на AMD Radeon / NPU
|
||||
|
||||
**Дата**: 2026-03-23
|
||||
**Железо**: AMD Ryzen 7 8845H, Radeon 780M iGPU (RDNA3, 12 CU), XDNA 1 NPU (16 TOPS)
|
||||
|
||||
**Статус:** архив. Перенесено из ветки `feature/amd-acceleration` (март 2026) ради истории решений. Выводы не реализованы: ADR-003 выбрал OpenVINO GenAI вместо whisper.cpp, а автоматический выбор устройства закреплён за ONNX CPU (#2).
|
||||
|
||||
## Мотивация
|
||||
|
||||
Бенчмарк ([2026-03-23-transcription-quality-ryzen.md](../benchmarks/2026-03-23-transcription-quality-ryzen.md)) показал: лучшее качество
|
||||
даёт CTranslate2 float32/int8_float32, но это 600–880с на 15-минутную запись.
|
||||
OpenVINO fp16 быстрее (240с), но теряет качество на русской речи.
|
||||
|
||||
Нужен путь: качество float32 + скорость GPU.
|
||||
|
||||
## Вариант 1: DirectML (onnxruntime-directml)
|
||||
|
||||
### Вердикт: ❌ НЕ рекомендуется
|
||||
|
||||
**Блокирующая проблема**: оператор `DecoderMaskedMultiHeadAttention` не реализован
|
||||
в DirectML ([Olive#1213](https://github.com/microsoft/Olive/issues/1213), открыт с июня 2024).
|
||||
Whisper-декодер fallback-ится на CPU — GPU ускоряет только encoder, выигрыш минимален.
|
||||
|
||||
**DirectML в maintenance mode** — Microsoft переключился на Windows ML (WinML).
|
||||
Команда DML расформирована, новых операторов не будет. Фикс маловероятен.
|
||||
|
||||
**WinML** (замена DirectML) — GA с сентября 2025, но Python-пакет `onnxruntime-winml`
|
||||
ещё не доступен. Требует Windows 11 25H2+. Потенциально интересен в будущем.
|
||||
|
||||
## Вариант 2: whisper.cpp + Vulkan ✅
|
||||
|
||||
### Вердикт: рекомендуется как основной путь
|
||||
|
||||
**Почему:**
|
||||
- Проверен на AMD Radeon 780M — баг softmax ([#2596](https://github.com/ggml-org/whisper.cpp/issues/2596))
|
||||
исправлен в декабре 2024
|
||||
- **3–4x ускорение** vs CPU на Radeon 680M (RDNA2), 780M (RDNA3) должен быть не хуже
|
||||
- Весь pipeline внутри: аудио → mel → encoder → decoder → beam search
|
||||
- GGUF-модели компактнее ONNX
|
||||
- Активная разработка, Vulkan-бэкенд зрелый
|
||||
|
||||
**Бенчмарки (чужие, похожее железо):**
|
||||
|
||||
| GPU | Модель | Результат |
|
||||
|-----|--------|-----------|
|
||||
| Radeon 680M (RDNA2) | medium | ~3–4x быстрее CPU |
|
||||
| Ryzen 5 4500U (Vega) | medium | 1ч аудио за ~25 мин |
|
||||
| Ryzen AI Max+ 395 (Strix Halo) | large-v3-turbo | 1ч20м аудио за ~3 мин |
|
||||
|
||||
**Память:** Radeon 780M использует системную RAM. whisper-medium в GGUF ≈ 1.5 ГБ —
|
||||
помещается с запасом.
|
||||
|
||||
### Интеграция в проект
|
||||
|
||||
Новый бэкенд `backends/whisper_cpp.py`, реализующий Backend Protocol:
|
||||
|
||||
```
|
||||
uv add pywhispercpp # Python-биндинги к whisper.cpp
|
||||
```
|
||||
|
||||
Либо Vulkan-сборка whisper.cpp как subprocess (более надёжно,
|
||||
но менее удобно для пользователя).
|
||||
|
||||
**Зависимость в pyproject.toml:**
|
||||
```toml
|
||||
# Опциональная, т.к. требует Vulkan-совместимый GPU
|
||||
"pywhispercpp>=0.4; sys_platform == 'win32'"
|
||||
```
|
||||
|
||||
Либо через dependency group:
|
||||
```toml
|
||||
[dependency-groups]
|
||||
vulkan = ["pywhispercpp>=0.4"]
|
||||
```
|
||||
|
||||
Тогда установка: `uv sync --group vulkan`
|
||||
|
||||
**Открытые вопросы:**
|
||||
- pywhispercpp собран с Vulkan или нужна своя сборка?
|
||||
- Качество GGUF-квантизации vs CTranslate2 float32 — нужен бенчмарк
|
||||
- Формат моделей другой (GGUF vs CTranslate2/OpenVINO) — ещё одно скачивание
|
||||
|
||||
## Вариант 3: AMD NPU (XDNA)
|
||||
|
||||
### Вердикт: ⏳ рано для XDNA 1, перспективно для XDNA 2
|
||||
|
||||
**Что есть:**
|
||||
- Ryzen AI Software 1.7.0 (февраль 2026) поддерживает 8845H (Hawk Point)
|
||||
- [AMD LIRA](https://github.com/amd/LIRA) — CLI для ASR на NPU, поддерживает whisper base/small/medium
|
||||
- [AMD-форк whisper.cpp](https://github.com/amd/whisper.cpp) с NPU-бэкендом
|
||||
- Pre-квантизированные модели на HuggingFace (`amd/NPU-Whisper-Base-Small`)
|
||||
|
||||
**Ограничения на XDNA 1 (8845H):**
|
||||
- 16 TOPS — в 3 раза слабее XDNA 2 (50 TOPS)
|
||||
- **Только encoder** на NPU, decoder на CPU — ускорение частичное
|
||||
- whisper-large-v3 не помещается
|
||||
- Установка через отдельный installer AMD, не pip/uv
|
||||
- Форк whisper.cpp не в mainline — риск отставания
|
||||
|
||||
**Когда станет интересно:**
|
||||
- На железе с XDNA 2 (Ryzen AI 300+, 50 TOPS)
|
||||
- Когда AMD выпустит pip-пакет или upstreamит в whisper.cpp
|
||||
- Для сценариев, где CPU/GPU заняты другой работой
|
||||
|
||||
## Сводка
|
||||
|
||||
| Подход | Применимость | Ускорение | Сложность | Стабильность |
|
||||
|--------|-------------|-----------|-----------|--------------|
|
||||
| **whisper.cpp + Vulkan** | ✅ высокая | 3–4x vs CPU | низкая | хорошая |
|
||||
| DirectML (onnxruntime) | ❌ заблокирован | — | — | — |
|
||||
| AMD NPU (XDNA 1) | ⚠️ ограничен | частичное | высокая | средняя |
|
||||
| WinML (будущее) | ⏳ не готов | неизвестно | средняя | — |
|
||||
|
||||
## Детали по whisper.cpp + Vulkan
|
||||
|
||||
### Модели (GGML .bin формат, НЕ GGUF)
|
||||
|
||||
Скачивать с https://huggingface.co/ggerganov/whisper.cpp/tree/main
|
||||
|
||||
| Модель | Файл | Размер |
|
||||
|--------|------|--------|
|
||||
| medium f16 | `ggml-medium.bin` | 1.53 ГБ |
|
||||
| medium q8_0 | `ggml-medium-q8_0.bin` | 823 МБ |
|
||||
| medium q5_0 | `ggml-medium-q5_0.bin` | 539 МБ |
|
||||
| large-v3 f16 | `ggml-large-v3.bin` | 3.1 ГБ |
|
||||
| large-v3 q5_0 | `ggml-large-v3-q5_0.bin` | 1.08 ГБ |
|
||||
| large-v3-turbo f16 | `ggml-large-v3-turbo.bin` | 1.62 ГБ |
|
||||
| large-v3-turbo q8_0 | `ggml-large-v3-turbo-q8_0.bin` | 874 МБ |
|
||||
|
||||
### Python-биндинги
|
||||
|
||||
**pywhispercpp** (v1.4.1) — pip wheel CPU-only. Для Vulkan нужна сборка из исходников:
|
||||
```bash
|
||||
GGML_VULKAN=1 pip install git+https://github.com/absadiki/pywhispercpp
|
||||
```
|
||||
Требует: VulkanSDK, cmake, C++ компилятор.
|
||||
|
||||
### Subprocess-подход (альтернатива)
|
||||
|
||||
whisper-cli принимает WAV 16kHz mono, выдаёт JSON с таймкодами:
|
||||
```bash
|
||||
whisper-cli -m ggml-medium.bin -f audio.wav -l ru -oj -of output
|
||||
```
|
||||
|
||||
Для видео/mp3 нужна предварительная конвертация через ffmpeg (PyAV уже есть в зависимостях).
|
||||
|
||||
### Сборка whisper.cpp с Vulkan
|
||||
|
||||
```bash
|
||||
# Linux/WSL
|
||||
sudo apt install cmake build-essential libvulkan-dev
|
||||
git clone https://github.com/ggml-org/whisper.cpp
|
||||
cd whisper.cpp
|
||||
cmake -B build -DGGML_VULKAN=ON -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build build --config Release -j$(nproc)
|
||||
# -> build/bin/whisper-cli
|
||||
```
|
||||
|
||||
## Прототип: whisper.cpp в WSL2 (2026-03-23)
|
||||
|
||||
### Сборка
|
||||
|
||||
whisper.cpp собран с `-DGGML_VULKAN=ON`, но **Vulkan GPU не обнаружен**:
|
||||
|
||||
```
|
||||
ggml_vulkan: No devices found.
|
||||
whisper_backend_init_gpu: no GPU found
|
||||
```
|
||||
|
||||
**Причина**: WSL2 не предоставляет Vulkan-доступ к AMD GPU.
|
||||
- `/dev/dxg` есть (DirectX 12 paravirtualization), но **dozen** (Vulkan-over-D3D12) драйвер
|
||||
отсутствует в текущей mesa (25.2.8)
|
||||
- RADV (нативный Vulkan) не работает — нет `/dev/dri/renderD128`
|
||||
- Единственный Vulkan-девайс — `llvmpipe` (CPU-эмуляция), whisper.cpp его игнорирует
|
||||
|
||||
Whisper.cpp отработал **на CPU** (4 потока, AVX512).
|
||||
|
||||
### Скорость (CPU fallback)
|
||||
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| Модель | ggml-medium.bin (f16, 1533 МБ) |
|
||||
| Аудио | 14:41 (881с), русский, 3 участника |
|
||||
| Общее время | **886с** |
|
||||
| encode | 368с (32 прохода, 11.5с/проход) |
|
||||
| batchd (decode) | 424с |
|
||||
| Потоки | 4 / 16 |
|
||||
| Beam search | 5 beams + best of 5 |
|
||||
|
||||
Сравнение с бенчмарком (тот же аудиофайл):
|
||||
|
||||
| Конфигурация | Время, с |
|
||||
|---|---|
|
||||
| OpenVINO fp16 (medium) | ~240 |
|
||||
| CT2 int8_float32 (medium) | ~599 |
|
||||
| CT2 float32 (medium) | ~881 |
|
||||
| **whisper.cpp CPU (medium f16)** | **~886** |
|
||||
|
||||
На CPU whisper.cpp ≈ CTranslate2 float32. Без GPU ускорения нет.
|
||||
|
||||
### Качество (сравнение с бенчмарком)
|
||||
|
||||
#### Техтермины
|
||||
|
||||
| Термин | whisper.cpp (CPU) | CT2 f32 | CT2 int8f32 |
|
||||
|--------|------------------|---------|-------------|
|
||||
| DWH | ✅ DWH | ⚠️ ДВХ | ✅ DWH |
|
||||
| Teradata | ✅ TeraData | ⚠️ Тирадату | ✅ Teradata |
|
||||
| NiFi | ⚠️ Найфай/Найфаю | ⚠️ Найфай | ✅ NiFi |
|
||||
| FineBI | ✅ fineBI/FNBI | ✅ FNBI | ✅ finebi/FNBI |
|
||||
| Alation | ✅ Allation | ✅ Allation | ✅ Allation |
|
||||
| ETL | ✅ ETL | ✅ ETL | ✅ ETL |
|
||||
| DBC Tables V | ✅ DBC tables-V | ✅ | ✅ |
|
||||
| OpenLineage | ✅ OpenLinage.io | ✅ OpenLinage.io | ✅ OpenLinage.io |
|
||||
|
||||
#### Русская разговорная речь
|
||||
|
||||
| Фраза | whisper.cpp (CPU) | CT2 f32 | CT2 int8f32 |
|
||||
|-------|------------------|---------|-------------|
|
||||
| "на другую мониторочку" | ✅ "на другом мониторчике" | ✅ "мониторчику" | ⚠️ "мою точку" |
|
||||
| "верхнеуровневое" | ✅ | ✅ | ✅ |
|
||||
| "датарентген" | ✅ "даторентген" | ✅ "даторентген" | ✅ "даторентген" |
|
||||
| "девовские схемы" | ✅ | ✅ | ✅ |
|
||||
| "с накиданными правами" | ✅ | ✅ | ✅ |
|
||||
| "Вьюхи" | ⚠️ "Yuhi" | ✅ "Вьюхи" | ⚠️ "Yuhi" |
|
||||
|
||||
#### Структура и пунктуация
|
||||
|
||||
| Аспект | whisper.cpp (CPU) | CT2 f32 | CT2 int8f32 |
|
||||
|--------|------------------|---------|-------------|
|
||||
| Пунктуация | хорошая | хорошая | хорошая |
|
||||
| Длина сегментов | 1–10 сек | ~1 мин | ~1 мин |
|
||||
|
||||
**Вывод по качеству**: whisper.cpp medium f16 на уровне CTranslate2 float32/int8_float32.
|
||||
Качество сопоставимо, различия минимальны. Сегменты короче (1–10 сек vs ~1 мин) —
|
||||
это даже лучше для навигации по транскрипту.
|
||||
|
||||
### Блокер: Vulkan в WSL2 для AMD
|
||||
|
||||
Для получения GPU-ускорения нужно одно из:
|
||||
|
||||
1. **Нативный Linux** (dual boot / bare metal) — RADV драйвер работает с Radeon 780M
|
||||
2. **Windows-сборка** — нативный Vulkan через AMD Adrenalin драйвер
|
||||
3. **WSL2 + dozen** — экспериментальный Vulkan-over-D3D12 драйвер mesa.
|
||||
Статус: [в разработке](https://gitlab.freedesktop.org/mesa/mesa/-/merge_requests?search=dozen),
|
||||
не включён в стандартную mesa Ubuntu.
|
||||
Потенциально можно собрать mesa из исходников с `-Dvulkan-drivers=microsoft-experimental`
|
||||
|
||||
**Рекомендация**: попробовать Windows-сборку whisper.cpp — это самый простой путь
|
||||
к Vulkan + Radeon 780M. Либо собрать mesa с dozen для WSL2.
|
||||
|
||||
## Прототип: whisper.cpp + Vulkan на Windows (2026-03-23)
|
||||
|
||||
### Сборка
|
||||
|
||||
whisper.cpp собран на Windows с MinGW (MSYS2) + Vulkan:
|
||||
```
|
||||
cmake -B build-vulkan -G "MinGW Makefiles" -DGGML_VULKAN=ON -DCMAKE_BUILD_TYPE=Release
|
||||
```
|
||||
|
||||
Vulkan GPU обнаружен:
|
||||
```
|
||||
ggml_vulkan: Found 1 Vulkan devices:
|
||||
ggml_vulkan: 0 = AMD Radeon(TM) 780M (AMD proprietary driver) | uma: 1 | fp16: 1 |
|
||||
warp size: 64 | shared memory: 32768 | int dot: 1 | matrix cores: KHR_coopmat
|
||||
```
|
||||
|
||||
### Проблема: зацикливание (repetition loop)
|
||||
|
||||
Первый запуск (дефолтные параметры): **520с**, но с ~07:00 модель зациклилась
|
||||
на фразе "Ну, опять же, они могут завести…" — повторяется ~170 раз до конца записи.
|
||||
Потеряна половина транскрипта.
|
||||
|
||||
**Причина**: whisper.cpp передаёт контекст предыдущего сегмента в декодер (`-mc -1`),
|
||||
что провоцирует repetition loop на длинных записях с паузами/перебивками.
|
||||
|
||||
**Решение**: `-mc 0` (не передавать контекст между сегментами).
|
||||
|
||||
### Скорость (Vulkan, -mc 0)
|
||||
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| Модель | ggml-medium.bin (f16, 1533 МБ) на Vulkan GPU |
|
||||
| Аудио | 14:41 (881с), русский, 3 участника |
|
||||
| Общее время | **432с** |
|
||||
| encode | 123.5с (35 проходов, 3.5с/проход) |
|
||||
| batchd (decode) | 252.7с |
|
||||
| Потоки | 4 / 16 |
|
||||
|
||||
Сравнение с бенчмарком (тот же аудиофайл):
|
||||
|
||||
| Конфигурация | Время, с | Ускорение vs CPU f32 |
|
||||
|---|---|---|
|
||||
| OpenVINO fp16 (medium) | ~240 | 3.7x |
|
||||
| **whisper.cpp Vulkan + Radeon 780M** | **~432** | **2.0x** |
|
||||
| **CT2 int8_float32, 8 threads** | **~458** | **1.9x** |
|
||||
| CT2 int8_float32, 16 threads | ~498 | 1.8x |
|
||||
| CT2 int8_float32, default (4 threads) | ~599 | 1.5x |
|
||||
| CT2 float32 / whisper.cpp CPU | ~881-886 | 1.0x |
|
||||
|
||||
**Оптимизация потоков**: CTranslate2 по умолчанию использует 4 потока (`cpu_threads=0`).
|
||||
На Ryzen 7 8845H (8 ядер / 16 потоков) оптимум — **8 потоков** (по числу физических ядер).
|
||||
16 потоков (SMT/hyperthreading) не помогают — контекст-свитчинг нивелирует выигрыш.
|
||||
Управление: `OMP_NUM_THREADS=8` или параметр `cpu_threads` в WhisperModel.
|
||||
|
||||
CT2 int8_float32 с 8 потоками (**458с**) почти догоняет whisper.cpp Vulkan (**432с**)
|
||||
без дополнительных зависимостей.
|
||||
|
||||
### Качество (Vulkan, -mc 0)
|
||||
|
||||
#### Техтермины
|
||||
|
||||
| Термин | whisper.cpp Vulkan | CT2 int8f32 |
|
||||
|--------|-------------------|-------------|
|
||||
| DWH | ✅ DWH | ✅ DWH |
|
||||
| Teradata | ✅ TeraData | ✅ Teradata |
|
||||
| NiFi | ⚠️ IFI | ✅ NiFi |
|
||||
| FineBI | ✅ FindBI/FineBI/FNBI | ✅ finebi/FNBI |
|
||||
| Alation | ✅ Allation | ✅ Allation |
|
||||
| ETL | ✅ ETL | ✅ ETL |
|
||||
| DBC Tables V | ⚠️ dbc tables v (lowercase) | ✅ DBC Tables V |
|
||||
| OpenLineage | ✅ OpenLinage.io | ✅ OpenLinage.io |
|
||||
| Вьюхи | ✅ Вьюхи | ⚠️ Yuhi |
|
||||
|
||||
#### Русская разговорная речь и пунктуация
|
||||
|
||||
Качество сопоставимо с CT2 int8_float32. Пунктуация хорошая в обоих случаях.
|
||||
Сегменты у whisper.cpp короче (2-10 сек vs ~1 мин) — удобнее для навигации.
|
||||
|
||||
### Вывод
|
||||
|
||||
whisper.cpp + Vulkan на Radeon 780M даёт **432с** — всего **28% быстрее** CT2 int8_float32
|
||||
(599с). Разница недостаточна, чтобы оправдать сложность интеграции:
|
||||
|
||||
- Отдельная сборка whisper.cpp (cmake + MinGW + VulkanSDK)
|
||||
- Другой формат моделей (GGML вместо CTranslate2)
|
||||
- Workaround `-mc 0` от зацикливания
|
||||
- Конвертация в WAV (whisper.cpp не принимает mp4 напрямую)
|
||||
- Ещё одна зависимость для пользователя
|
||||
|
||||
**CT2 int8_float32 остаётся оптимальным выбором**: уже встроен в проект, zero-config,
|
||||
хорошее качество, приемлемая скорость.
|
||||
|
||||
## Рекомендуемый план (обновлённый)
|
||||
|
||||
1. ~~**Прототип в WSL** — собрать whisper.cpp с Vulkan, прогнать тестовый файл~~ ✅ Сделано
|
||||
2. ~~**Качество подтверждено** — whisper.cpp medium f16 ≈ CT2 float32/int8_float32~~ ✅
|
||||
3. ~~**GPU-ускорение в WSL2** — Vulkan не видит AMD GPU~~ ✅ Подтверждено
|
||||
4. ~~**Windows-сборка с Vulkan** — 432с, 2x vs CPU, но всего 28% vs CT2 int8_float32~~ ✅
|
||||
5. **Решение**: whisper.cpp + Vulkan не оправдывает себя на Radeon 780M iGPU.
|
||||
CT2 int8_float32 — оптимальный дефолт для CPU без CUDA.
|
||||
6. NPU — отложить до XDNA 2 или до появления pip-пакета от AMD
|
||||
7. Пересмотреть при появлении дискретного AMD GPU или WinML с Python-поддержкой
|
||||
@@ -0,0 +1,167 @@
|
||||
# Владение выполнением транскрипции
|
||||
|
||||
**Статус:** реализовано. Правила выбора исполнения и optional CUDA — в #2,
|
||||
владение выполнением (`Transcriber`), бюджет потоков ONNX и диагностика —
|
||||
в #56. Windows GPU-прогон без Toolkit остаётся невыполненным (см. ADR-001).
|
||||
|
||||
## Зачем
|
||||
|
||||
Основное направление развития — поддерживаемые модели и движки распознавания,
|
||||
прежде всего ONNX. В перспективе нужны аппаратные варианты исполнения на Mac
|
||||
и через Windows ML, но их внедрение не входит в этот рефакторинг.
|
||||
|
||||
Сейчас CLI хранит модель, backend, путь к модели и фактическое устройство,
|
||||
передаёт их в `_transcribe_file` и обновляет после fallback. Для ONNX часть
|
||||
согласованного состояния находится в adapter, а загруженная модель передаётся
|
||||
отдельно. Это увеличивает interface: caller должен знать порядок вызовов
|
||||
и поддерживать согласованность значений.
|
||||
|
||||
`device` одновременно обозначает движок и аппаратное исполнение. До #2 значение
|
||||
`onnx` выбирало adapter, но не закрепляло CPU: `onnx-asr` по умолчанию использует
|
||||
доступные execution providers, хотя CLI писал `ONNX (CPU)`. В #2 для ASR и VAD
|
||||
закреплён CPU provider. Параметр `cpu_threads` всё ещё принимается ONNX adapter,
|
||||
но не используется при загрузке ASR и VAD.
|
||||
|
||||
## Согласованные ограничения
|
||||
|
||||
- Сохраняются ONNX-ASR, OpenVINO и FasterWhisper. Удаление CUDA-библиотек из
|
||||
обязательной установки не означает удаление FasterWhisper или его CPU-пути.
|
||||
- Приоритет — подключение моделей и вариантов исполнения. Промежуточное
|
||||
сохранение и возобновление обработки совещаний не нужны для этой работы.
|
||||
- Автоматически выбираются только проверенные сочетания модели и исполнения.
|
||||
Новое аппаратное исполнение сначала подключается явно и проверяется отдельно.
|
||||
- CUDA-зависимости устанавливаются по желанию пользователя. Способ установки
|
||||
и включения должен быть описан в документации.
|
||||
- Наличие драйвера NVIDIA не доказывает совместимость железа с runtime.
|
||||
Ноутбук владельца с установленным драйвером и несовместимым старым GPU —
|
||||
обязательный сценарий для проверки нового поведения.
|
||||
- Диаризация остаётся необязательным последующим проходом. Сбой «два
|
||||
собеседника на чёткой записи, найден один говорящий» требует отдельного
|
||||
исследования качества; этот рефакторинг его не исправляет.
|
||||
|
||||
## Форма module
|
||||
|
||||
External seam проходит между caller и выполнением распознавания. Caller
|
||||
создаёт один module для согласованных настроек запуска и последовательно
|
||||
передаёт ему файлы. Module владеет загруженной моделью, связанным adapter,
|
||||
фактическими настройками и допустимыми переходами исполнения. Caller получает
|
||||
результат распознавания и сведения о выполнении, а не внутреннее состояние,
|
||||
которое нужно передать обратно при следующем файле.
|
||||
|
||||
Module создаётся после prescan, когда есть работа. Загрузка и проверка
|
||||
возможностей завершаются до распознавания первого файла: это сохраняет
|
||||
проверку пословного контракта перед включённой диаризацией. Один и тот же
|
||||
interface используется для одного файла и для батча. События прогресса остаются
|
||||
доступны CLI. Публичная функция `transcribe` сохраняется как путь одного файла
|
||||
через тот же module; конкретные имена новых типов здесь не закрепляются.
|
||||
|
||||
Внутри различаются поддерживаемая модель, движок распознавания и способ
|
||||
исполнения. Существующие значения CLI и TOML преобразуются в этот выбор при
|
||||
подготовке запуска. Backend остаётся internal seam с реальными adapters;
|
||||
каталог ONNX, VAD, квантизация и перевод результата библиотеки в сегменты
|
||||
распознавания и слова с временной привязкой остаются в ONNX adapter.
|
||||
Отдельная иерархия adapters для каждого execution provider пока не нужна.
|
||||
|
||||
CLI сохраняет выбор файлов, prescan, пользовательский вывод и итоговый статус.
|
||||
Форматирование, запись Markdown и правила диаризации не поглощаются новым
|
||||
module. Это ограничивает первый шаг владением ASR и его настройками.
|
||||
|
||||
Depth оценивается по обязанностям caller: ему больше не нужно согласовывать
|
||||
model/backend/path/device. Locality — изменения загрузки и исполнения
|
||||
сосредоточены в module и соответствующем adapter. Leverage — один interface
|
||||
покрывает одиночный запуск, батч и их тесты.
|
||||
|
||||
## Выбор исполнения и ошибки
|
||||
|
||||
Ручное включение CUDA состоит из двух явных действий: установки зависимостей
|
||||
и выбора CUDA через CLI либо TOML. Extra `cuda` поддерживает Linux/WSL x86_64
|
||||
и Windows x64 без обязательного системного Toolkit; совместимый драйвер
|
||||
устанавливается отдельно. CUDA не участвует
|
||||
в `auto`, даже если найден `nvidia-smi` или установлены её библиотеки.
|
||||
Так установка драйвера не меняет движок и модель по умолчанию.
|
||||
|
||||
Начальный автоматический путь — ONNX с CPU и существующей моделью по умолчанию.
|
||||
Для ONNX список providers задаётся явно; наличие CoreML в macOS wheel не
|
||||
включает его автоматически. Это делает политику проекта определённой, но
|
||||
не заменяет проверку работы на настоящем Mac.
|
||||
|
||||
Явный аппаратный выбор не подменяется молча другим исполнением. При отсутствии
|
||||
CUDA-библиотек выводится инструкция подключения; при несовместимости железа
|
||||
объясняется отказ выбранного пути. Установка библиотек не предлагается как
|
||||
универсальное исправление аппаратной несовместимости. Остальные существующие
|
||||
правила ошибок и разрешённых fallback сохраняются внутри module; состояние
|
||||
после перехода остаётся согласованным для следующего файла.
|
||||
|
||||
Сведения о выполнении различают запрошенное и выбранное исполнение, модель,
|
||||
квантизацию и настройки потоков. Они не раскрывают model handle. Для ONNX
|
||||
наличие или регистрация provider не выдаётся за доказательство выполнения
|
||||
всего графа на GPU/NPU. Отладочный вывод должен помогать выяснить конфигурацию
|
||||
на чужой машине, включая версии runtime и providers, без доступа к записи.
|
||||
|
||||
## Проверка результата
|
||||
|
||||
Основные сценарии проверяются через external interface с подменой внешних
|
||||
движков, при работающей оркестрации проекта:
|
||||
|
||||
- Пустой prescan не загружает модели; два файла переиспользуют одну модель.
|
||||
- Добавление каталожной ONNX-модели не требует изменений в CLI.
|
||||
- Выбранная квантизация и бюджет потоков действительно передаются ASR и VAD;
|
||||
нулевой бюджет сохраняет настройки потоков библиотек.
|
||||
- Наличие `nvidia-smi` при обычной установке не запускает CUDA и не ведёт
|
||||
через ошибку GPU к FasterWhisper CPU.
|
||||
- Явная CUDA с отсутствующими библиотеками и с несовместимым GPU даёт разные
|
||||
содержательные ошибки. Автоматическая смена модели не маскирует отказ.
|
||||
- Случайно доступный непроверенный ONNX provider не меняет автоматический путь.
|
||||
- Разрешённый fallback не требует переноса состояния в CLI; обычная ошибка
|
||||
файла не повреждает состояние для последующих файлов батча.
|
||||
- Сохраняются явные CPU/OpenVINO-пути, порядок слов, пословный контракт,
|
||||
прогресс, прерывание и существующее поведение необязательной диаризации.
|
||||
|
||||
Тесты ручного переноса внутренних полей заменяются проверками этих сценариев.
|
||||
Тесты преобразования результатов adapters сохраняются на их internal seams.
|
||||
Mock-тесты не подтверждают ускорение или совместимость на Mac/Windows ML;
|
||||
для признания сочетания проверенным нужен реальный прогон с оценкой результата.
|
||||
|
||||
## Документация и прежние решения
|
||||
|
||||
README должен различать обычную установку и подключение CUDA: дополнительные
|
||||
зависимости через один extra на Linux/WSL x86_64 и Windows x64, внешние
|
||||
требования драйвера и Visual C++ Runtime, явный запуск, настройку TOML
|
||||
и диагностику отсутствующих библиотек или несовместимого железа. Команды
|
||||
установки из клона и через `uv tool` проверяются при реализации. CUDA extra
|
||||
оформляется отдельным изменением, чтобы поставку можно было проверить
|
||||
независимо от рефакторинга выполнения.
|
||||
|
||||
При реализации нужно отразить пересмотр прежних решений:
|
||||
|
||||
- [ADR-001](../adr/001-cuda-bootstrap.md): обязательная cuBLAS ради готовности
|
||||
CUDA сразу после установки сменяется подключением по желанию пользователя.
|
||||
- [ADR-002](../adr/002-batch-and-config.md): состояние модели больше не
|
||||
возвращается caller для переноса между файлами. Prescan-first сохраняется.
|
||||
- [ADR-003](../adr/003-pluggable-backends.md): движок и аппаратное исполнение
|
||||
различаются внутри module; device больше не считается полным описанием
|
||||
исполнения. Сохраняются adapters, ленивые imports и единый владелец загрузки.
|
||||
- [ADR-006](../adr/006-onnx-asr-backend.md): автоматический выбор CUDA по
|
||||
`nvidia-smi` заменяется явным включением; модель ONNX по умолчанию сохраняется.
|
||||
- [ADR-007](../adr/007-word-level-speaker-diarization.md): правила пословного
|
||||
результата и диаризации этим решением не пересматриваются.
|
||||
|
||||
Основания для будущих аппаратных вариантов уже собраны в
|
||||
[исследовании runtime](../research/2026-08-12-onnx-runtime-openvino-lifecycle.md).
|
||||
Актуальные описания механизмов: [CoreML EP](https://onnxruntime.ai/docs/execution-providers/CoreML-ExecutionProvider.html)
|
||||
и [Windows ML](https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/overview).
|
||||
Их наличие не является подтверждением выбранных моделей на этих платформах.
|
||||
|
||||
## Объём задач
|
||||
|
||||
Для основного рефакторинга достаточно одной задачи с несколькими коммитами:
|
||||
владение моделью, перевод callers, настройки ONNX и проверка поведения входят
|
||||
в один принимаемый результат. Отдельных задач на каждый файл или коммит нет.
|
||||
|
||||
Существующая [задача #2](https://git.dementev.space/ddmitry/local-transcriber/issues/2)
|
||||
получает принятое решение об optional CUDA вместо дальнейшего исследования:
|
||||
зависимости, ручное включение, автоматический ONNX CPU, диагностика CUDA
|
||||
и документация. Она выполняется первой и даёт основному рефакторингу
|
||||
определённые правила выбора. Фиксация CPU providers ASR/VAD относится к #2;
|
||||
бюджет потоков, владение состоянием и общая диагностика исполнения — к основной
|
||||
задаче. Работа над CoreML, Windows ML и качеством диаризации сюда не входит.
|
||||
+8
-1
@@ -8,16 +8,23 @@ dependencies = [
|
||||
"typer>=0.24.1,<1",
|
||||
"rich>=14.3.3,<15",
|
||||
"faster-whisper>=1.2.1,<2",
|
||||
# CUDA-зависимости Windows проверены для этой сборки; см. ADR-001.
|
||||
"ctranslate2==4.8.1",
|
||||
"httpx>=0.28,<1",
|
||||
"socksio>=1.0.0,<2",
|
||||
# При обновлении сверить блок tool.uv.dependency-metadata ниже.
|
||||
"sherpa-onnx==1.13.6",
|
||||
"nvidia-cublas-cu12>=12.4,<13; sys_platform == 'linux' and platform_machine == 'x86_64'",
|
||||
"openvino-genai>=2026.3.0.0,<2026.4; sys_platform != 'darwin' and (platform_machine == 'x86_64' or platform_machine == 'AMD64')",
|
||||
"onnx-asr[cpu,hub]>=0.12,<0.13",
|
||||
"onnxruntime>=1.28,<2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
cuda = [
|
||||
# В 12.9.2 добавилась NVRTC: обновлять после проверки DLL и bootstrap.
|
||||
"nvidia-cublas-cu12==12.9.1.4; (sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and (platform_machine == 'AMD64' or platform_machine == 'x86_64'))",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
transcribe = "local_transcriber.cli:app"
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""
|
||||
Preload CUDA-библиотек из pip-пакетов до импорта ctranslate2.
|
||||
Подготовка CUDA-библиотек из extra перед созданием CUDA-модели.
|
||||
|
||||
Проблема: ctranslate2 на Linux делает dlopen("libcublas.so.12"),
|
||||
но не знает, что библиотека лежит внутри pip-пакета nvidia-cublas-cu12.
|
||||
На Windows ctranslate2 решает это сам через os.add_dll_directory.
|
||||
На Windows ctranslate2 не добавляет nvidia/cublas/bin в поиск DLL.
|
||||
Регистрируем каталог и загружаем cuBLAS Lt, затем cuBLAS по полному пути.
|
||||
Храним DLL и регистрацию каталога до завершения процесса.
|
||||
|
||||
Решение: загружаем libcublas.so.12 по полному пути через ctypes.CDLL
|
||||
с флагом RTLD_GLOBAL до первого import ctranslate2. Динамический линкер
|
||||
с флагом RTLD_GLOBAL до создания CUDA-модели. Динамический линкер
|
||||
кеширует загруженные библиотеки по soname — когда ctranslate2 потом
|
||||
вызовет dlopen("libcublas.so.12"), линкер вернёт уже загруженный handle.
|
||||
|
||||
@@ -21,20 +23,24 @@ import glob
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Закрытие регистрации удаляет каталог из поиска DLL, поэтому храним handles.
|
||||
_dll_directories: dict[str, object] = {}
|
||||
_dll_libraries: dict[str, object] = {}
|
||||
|
||||
|
||||
def ensure_cublas_loadable() -> None:
|
||||
"""Загружает libcublas из nvidia-cublas-cu12 в адресное пространство процесса.
|
||||
"""Загружает cuBLAS из nvidia-cublas-cu12 в адресное пространство процесса.
|
||||
|
||||
Вызывать ДО первого import ctranslate2.
|
||||
Вызывать перед созданием CUDA-модели; CPU-путь не требует bootstrap.
|
||||
Безопасно вызывать многократно и на платформах без nvidia-cublas-cu12.
|
||||
"""
|
||||
if sys.platform != "linux":
|
||||
if sys.platform not in ("linux", "win32"):
|
||||
return
|
||||
|
||||
try:
|
||||
import nvidia.cublas # type: ignore[import-untyped]
|
||||
except ImportError:
|
||||
# nvidia-cublas-cu12 не установлен (Windows, macOS, или CPU-only setup)
|
||||
# Optional-пакет не установлен; системная cuBLAS всё ещё может работать.
|
||||
return
|
||||
|
||||
# nvidia.cublas может быть namespace package (__file__ == None),
|
||||
@@ -42,6 +48,21 @@ def ensure_cublas_loadable() -> None:
|
||||
cublas_paths = getattr(nvidia.cublas, "__path__", None)
|
||||
if not cublas_paths:
|
||||
return
|
||||
if sys.platform == "win32":
|
||||
for package_path in cublas_paths:
|
||||
bin_dir = os.path.join(package_path, "bin")
|
||||
if not os.path.isdir(bin_dir):
|
||||
continue
|
||||
if bin_dir not in _dll_directories:
|
||||
_dll_directories[bin_dir] = os.add_dll_directory(bin_dir)
|
||||
# cuBLAS зависит от Lt; явный preload также обслуживает LoadLibrary
|
||||
# внутри CTranslate2. Порядок здесь существенен.
|
||||
for filename in ("cublasLt64_12.dll", "cublas64_12.dll"):
|
||||
dll_path = os.path.join(bin_dir, filename)
|
||||
if dll_path not in _dll_libraries:
|
||||
_dll_libraries[dll_path] = ctypes.WinDLL(dll_path)
|
||||
return
|
||||
return
|
||||
cublas_lib_dir = os.path.join(cublas_paths[0], "lib")
|
||||
if not os.path.isdir(cublas_lib_dir):
|
||||
return
|
||||
|
||||
@@ -16,6 +16,9 @@ class Backend(Protocol):
|
||||
наследование не требуется.
|
||||
"""
|
||||
|
||||
engine: str
|
||||
"""Имя движка для сведений о выполнении."""
|
||||
|
||||
@property
|
||||
def word_timestamps_available(self) -> bool:
|
||||
"""Гарантирует ли выбранный backend/model пословные таймкоды."""
|
||||
@@ -53,3 +56,7 @@ class Backend(Protocol):
|
||||
) -> TranscribeResult:
|
||||
"""Транскрибирует файл, возвращает результат."""
|
||||
...
|
||||
|
||||
def runtime_info(self) -> dict[str, str]:
|
||||
"""Версии runtime и фактическая конфигурация для удалённой диагностики."""
|
||||
...
|
||||
@@ -6,21 +6,17 @@ from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# CUDA bootstrap — должен быть ДО импорта faster_whisper / ctranslate2
|
||||
from huggingface_hub import snapshot_download
|
||||
from huggingface_hub.errors import LocalEntryNotFoundError
|
||||
|
||||
from local_transcriber._cuda_bootstrap import ensure_cublas_loadable
|
||||
|
||||
ensure_cublas_loadable()
|
||||
|
||||
from faster_whisper import WhisperModel # noqa: E402
|
||||
from huggingface_hub import snapshot_download # noqa: E402
|
||||
from huggingface_hub.errors import LocalEntryNotFoundError # noqa: E402
|
||||
|
||||
from local_transcriber.types import ( # noqa: E402
|
||||
from local_transcriber.types import (
|
||||
Segment,
|
||||
TranscribeResult,
|
||||
Word,
|
||||
WordTimestampsUnavailableError,
|
||||
)
|
||||
from local_transcriber.utils import package_version
|
||||
|
||||
MODEL_REPOS = {
|
||||
"tiny": "Systran/faster-whisper-tiny",
|
||||
@@ -48,10 +44,12 @@ MODEL_REQUIRED_FILES = [
|
||||
class FasterWhisperBackend:
|
||||
"""Бэкенд транскрипции через faster-whisper (CTranslate2)."""
|
||||
|
||||
engine = "faster-whisper"
|
||||
word_timestamps_available = True
|
||||
|
||||
def __init__(self):
|
||||
self.actual_compute_type: str | None = None
|
||||
self._cpu_threads = 0
|
||||
|
||||
def ensure_model_available(
|
||||
self,
|
||||
@@ -94,6 +92,12 @@ class FasterWhisperBackend:
|
||||
|
||||
cpu_threads: число потоков для CPU inference (0 = дефолт библиотеки, обычно 4).
|
||||
"""
|
||||
if device == "cuda":
|
||||
ensure_cublas_loadable()
|
||||
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
self._cpu_threads = cpu_threads
|
||||
try:
|
||||
return WhisperModel(
|
||||
model_path,
|
||||
@@ -157,6 +161,22 @@ class FasterWhisperBackend:
|
||||
words=words,
|
||||
)
|
||||
|
||||
def runtime_info(self) -> dict[str, str]:
|
||||
"""Версии CTranslate2/faster-whisper и число видимых CUDA-устройств."""
|
||||
import ctranslate2
|
||||
|
||||
try:
|
||||
cuda_devices = str(ctranslate2.get_cuda_device_count())
|
||||
except Exception as exc: # noqa: BLE001 — диагностика не должна ронять запуск
|
||||
cuda_devices = f"недоступно ({exc})"
|
||||
return {
|
||||
"faster_whisper": package_version("faster-whisper"),
|
||||
"ctranslate2": package_version("ctranslate2"),
|
||||
"cuda_devices": cuda_devices,
|
||||
"compute_type": self.actual_compute_type or "",
|
||||
"cpu_threads": str(self._cpu_threads) if self._cpu_threads else "по умолчанию",
|
||||
}
|
||||
|
||||
|
||||
def _notify(on_status: Callable[[str], None] | None, message: str) -> None:
|
||||
if on_status is not None:
|
||||
|
||||
@@ -15,6 +15,7 @@ from local_transcriber.types import (
|
||||
Word,
|
||||
WordTimestampsUnavailableError,
|
||||
)
|
||||
from local_transcriber.utils import package_version
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -131,6 +132,8 @@ def _normalize_quantization(compute_type: str) -> str | None:
|
||||
class OnnxAsrBackend:
|
||||
"""Бэкенд транскрипции через onnx-asr (ONNX Runtime)."""
|
||||
|
||||
engine = "onnx-asr"
|
||||
|
||||
def __init__(self, compute_type_explicit: bool = True):
|
||||
self._compute_type_explicit = compute_type_explicit
|
||||
self.actual_compute_type: str | None = None
|
||||
@@ -138,6 +141,9 @@ class OnnxAsrBackend:
|
||||
self._model_name: str | None = None
|
||||
self._model_spec: OnnxModelSpec | None = None
|
||||
self._vad: Any = None
|
||||
self._providers: list[str] = []
|
||||
self._quantization: str | None = None
|
||||
self._cpu_threads = 0
|
||||
|
||||
@property
|
||||
def word_timestamps_available(self) -> bool:
|
||||
@@ -191,20 +197,49 @@ class OnnxAsrBackend:
|
||||
|
||||
compute_type маппится в onnx-asr ``quantization`` — это суффикс файла
|
||||
модели; для unquantized (float32/fp32) нужно None, не строку.
|
||||
cpu_threads > 0 задаёт intra_op_num_threads сессиям ASR и VAD;
|
||||
0 оставляет настройки потоков onnxruntime.
|
||||
"""
|
||||
import onnx_asr
|
||||
|
||||
actual_compute_type = self.actual_compute_type or compute_type
|
||||
quantization = _normalize_quantization(actual_compute_type)
|
||||
|
||||
providers = ["CPUExecutionProvider"]
|
||||
session_kwargs: dict[str, Any] = {"providers": providers}
|
||||
if cpu_threads > 0:
|
||||
session_kwargs["sess_options"] = _session_options(cpu_threads)
|
||||
model = onnx_asr.load_model(
|
||||
model=model_path,
|
||||
quantization=quantization,
|
||||
**session_kwargs,
|
||||
)
|
||||
vad = onnx_asr.load_vad("silero")
|
||||
vad = onnx_asr.load_vad("silero", **session_kwargs)
|
||||
self._vad = vad
|
||||
self._providers = providers
|
||||
self._quantization = quantization
|
||||
self._cpu_threads = cpu_threads
|
||||
return model.with_vad(vad).with_timestamps()
|
||||
|
||||
def runtime_info(self) -> dict[str, str]:
|
||||
"""Версии ONNX Runtime/onnx-asr и providers, заданные сессиям ASR и VAD.
|
||||
|
||||
Доступность provider в wheel не означает, что граф выполняется на нём:
|
||||
сессии получают только явно заданный список.
|
||||
"""
|
||||
import onnxruntime
|
||||
|
||||
configured = ", ".join(self._providers)
|
||||
return {
|
||||
"onnxruntime": package_version("onnxruntime"),
|
||||
"onnx_asr": package_version("onnx-asr"),
|
||||
"available_providers": ", ".join(onnxruntime.get_available_providers()),
|
||||
"asr_providers": configured,
|
||||
"vad_providers": configured,
|
||||
"quantization": self._quantization or "float32",
|
||||
"intra_op_threads": str(self._cpu_threads) if self._cpu_threads else "по умолчанию",
|
||||
}
|
||||
|
||||
def transcribe(
|
||||
self,
|
||||
model: Any,
|
||||
@@ -278,7 +313,7 @@ class OnnxAsrBackend:
|
||||
fallback = _whisper_fallback_model(model_name)
|
||||
raise ValueError(
|
||||
f"Модель '{model_name}' относится к Whisper и не поддерживается "
|
||||
"ONNX-бэкендом. Без CUDA --device auto выбирает ONNX; "
|
||||
"ONNX-бэкендом. --device auto всегда выбирает ONNX CPU; "
|
||||
f"укажите --device openvino-cpu --model {model_name} на x86, "
|
||||
f"--device cpu --model {fallback} на любой платформе "
|
||||
f"или --device cuda --model {fallback} при NVIDIA GPU."
|
||||
@@ -313,6 +348,15 @@ class OnnxAsrBackend:
|
||||
)
|
||||
|
||||
|
||||
def _session_options(cpu_threads: int) -> Any:
|
||||
"""SessionOptions с бюджетом потоков; общий объект для ASR и VAD."""
|
||||
import onnxruntime
|
||||
|
||||
options = onnxruntime.SessionOptions()
|
||||
options.intra_op_num_threads = cpu_threads
|
||||
return options
|
||||
|
||||
|
||||
def _format_compute_types(quantizations: frozenset[str | None]) -> str:
|
||||
values = [_compute_type_for_quantization(value) for value in quantizations]
|
||||
return ", ".join(sorted(values))
|
||||
|
||||
@@ -19,6 +19,7 @@ from local_transcriber.types import (
|
||||
Word,
|
||||
WordTimestampsUnavailableError,
|
||||
)
|
||||
from local_transcriber.utils import package_version
|
||||
|
||||
# (model_alias, compute_type) → HF repo
|
||||
MODEL_REPOS: dict[tuple[str, str], str] = {
|
||||
@@ -56,6 +57,7 @@ MODEL_REQUIRED_FILES = [
|
||||
class OpenVINOBackend:
|
||||
"""Бэкенд транскрипции через openvino-genai WhisperPipeline."""
|
||||
|
||||
engine = "openvino"
|
||||
word_timestamps_available = True
|
||||
|
||||
def __init__(
|
||||
@@ -203,6 +205,18 @@ class OpenVINOBackend:
|
||||
words=words,
|
||||
)
|
||||
|
||||
def runtime_info(self) -> dict[str, str]:
|
||||
"""Версия OpenVINO, доступные устройства и выбранное фактически."""
|
||||
from openvino import Core
|
||||
|
||||
return {
|
||||
"openvino": package_version("openvino"),
|
||||
"available_devices": ", ".join(Core().available_devices),
|
||||
"device": self.actual_ov_device or "",
|
||||
"compute_type": self.actual_compute_type or "",
|
||||
"threads": "управляет OpenVINO, --threads не применяется",
|
||||
}
|
||||
|
||||
def _resolve_repo(self, model_name: str, compute_type: str) -> tuple[str, str]:
|
||||
"""Находит HF repo для пары (model, compute_type) с fallback.
|
||||
|
||||
|
||||
+88
-189
@@ -13,8 +13,6 @@ from rich.status import Status
|
||||
|
||||
from .config import (
|
||||
CliValues,
|
||||
ResolvedConfig,
|
||||
apply_device_defaults,
|
||||
load_config,
|
||||
resolve_defaults,
|
||||
)
|
||||
@@ -39,11 +37,12 @@ from .quality import (
|
||||
)
|
||||
from .speaker_diarizer import SpeakerDiarizer, load_speaker_diarizer
|
||||
from .transcriber import (
|
||||
ExecutionInfo,
|
||||
ExecutionRequest,
|
||||
Segment,
|
||||
Transcriber,
|
||||
TranscribeResult,
|
||||
_is_cuda_error,
|
||||
_transcribe_file,
|
||||
load_model,
|
||||
cuda_error_hint,
|
||||
)
|
||||
from .types import (
|
||||
UNKNOWN_LANGUAGE,
|
||||
@@ -53,10 +52,7 @@ from .types import (
|
||||
)
|
||||
from .utils import (
|
||||
build_output_path,
|
||||
detect_device,
|
||||
expand_globs,
|
||||
get_gpu_name,
|
||||
get_intel_gpu_name,
|
||||
has_existing_transcript,
|
||||
validate_input_file,
|
||||
)
|
||||
@@ -65,6 +61,13 @@ app = typer.Typer()
|
||||
console = Console(stderr=True)
|
||||
|
||||
|
||||
def _print_cuda_hint(exc: BaseException, device: str | None = None) -> None:
|
||||
"""Показывает подсказку, не заменяя исходную ошибку."""
|
||||
hint = cuda_error_hint(exc, device=device)
|
||||
if hint:
|
||||
console.print(hint, style="yellow", markup=False)
|
||||
|
||||
|
||||
def _show_cli_warning(
|
||||
message: Warning | str,
|
||||
*_args: object,
|
||||
@@ -88,21 +91,6 @@ def _with_cli_warning_renderer(
|
||||
return wrapped
|
||||
|
||||
|
||||
def _format_device_info(device_used: str) -> str:
|
||||
"""Формирует строку устройства для шапки транскрипта."""
|
||||
if device_used == "cuda":
|
||||
gpu_name = get_gpu_name()
|
||||
return f"CUDA ({gpu_name or 'Unknown GPU'})"
|
||||
if device_used == "openvino-gpu":
|
||||
gpu_name = get_intel_gpu_name()
|
||||
return f"OpenVINO ({gpu_name or 'Intel GPU'})"
|
||||
if device_used in ("openvino", "openvino-cpu"):
|
||||
return "OpenVINO (CPU)"
|
||||
if device_used == "onnx":
|
||||
return "ONNX (CPU)"
|
||||
return "CPU"
|
||||
|
||||
|
||||
def _format_language_mode(requested_language: str, result: TranscribeResult) -> str:
|
||||
"""Описывает источник языка, не выдавая профиль модели за детектор."""
|
||||
if requested_language != "auto":
|
||||
@@ -341,6 +329,7 @@ def main(
|
||||
)
|
||||
raise SystemExit(2)
|
||||
|
||||
requested_device: str | None = None
|
||||
try:
|
||||
config = load_config()
|
||||
cli_values: CliValues = {
|
||||
@@ -352,11 +341,17 @@ def main(
|
||||
}
|
||||
defaults = resolve_defaults(cli_values, config)
|
||||
diarize_enabled = defaults["diarize"] is True or speakers is not None
|
||||
requested_device = defaults["device"]
|
||||
|
||||
resolved_device = detect_device(defaults["device"])
|
||||
defaults = apply_device_defaults(defaults, resolved_device, cli_values, config)
|
||||
|
||||
ct_explicit = compute_type is not None or "compute_type" in config
|
||||
# Умолчания model/compute_type зависят от устройства — их разрешает module.
|
||||
request = ExecutionRequest(
|
||||
device=requested_device,
|
||||
model=defaults["model"],
|
||||
compute_type=defaults["compute_type"],
|
||||
language=defaults["language"],
|
||||
cpu_threads=threads,
|
||||
require_word_timestamps=diarize_enabled,
|
||||
)
|
||||
|
||||
expanded = expand_globs(files)
|
||||
if not expanded:
|
||||
@@ -373,22 +368,18 @@ def main(
|
||||
if is_batch:
|
||||
_run_batch(
|
||||
expanded,
|
||||
defaults,
|
||||
request,
|
||||
verbose,
|
||||
force,
|
||||
ct_explicit,
|
||||
cpu_threads=threads,
|
||||
diarize=diarize_enabled,
|
||||
speakers=speakers,
|
||||
)
|
||||
else:
|
||||
_run_single(
|
||||
expanded[0],
|
||||
defaults,
|
||||
request,
|
||||
output,
|
||||
verbose,
|
||||
ct_explicit,
|
||||
cpu_threads=threads,
|
||||
diarize=diarize_enabled,
|
||||
speakers=speakers,
|
||||
)
|
||||
@@ -398,21 +389,15 @@ def main(
|
||||
except SystemExit:
|
||||
raise
|
||||
except ValueError as exc:
|
||||
_print_cuda_hint(exc, requested_device)
|
||||
console.print(f"Ошибка: {exc}", style="red bold")
|
||||
raise SystemExit(1)
|
||||
except (FileNotFoundError,) as exc:
|
||||
_print_cuda_hint(exc, requested_device)
|
||||
console.print(f"Ошибка: {exc}", style="red bold")
|
||||
raise SystemExit(1)
|
||||
except Exception as exc:
|
||||
if _is_cuda_error(exc) and sys.platform == "win32":
|
||||
console.print(
|
||||
"GPU на Windows требует CUDA toolkit (включает cuBLAS).\n"
|
||||
"Установите одним из способов:\n"
|
||||
" choco install cuda\n"
|
||||
" winget install -e --id Nvidia.CUDA\n"
|
||||
"После установки перезапустите терминал.",
|
||||
style="yellow",
|
||||
)
|
||||
_print_cuda_hint(exc, requested_device)
|
||||
if verbose:
|
||||
console.print_exception()
|
||||
else:
|
||||
@@ -421,13 +406,50 @@ def main(
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def _print_execution_header(info: ExecutionInfo, verbose: bool) -> None:
|
||||
"""Печатает выбранное исполнение; в --verbose — версии runtime для диагностики."""
|
||||
console.print(
|
||||
f"Модель: [bold]{info.model}[/bold] "
|
||||
f"Устройство: [bold]{info.device}[/bold] "
|
||||
f"Compute: [bold]{info.compute_type}[/bold]"
|
||||
)
|
||||
if info.device == "openvino-gpu" and info.model != "large-v3":
|
||||
console.print(
|
||||
"Совет: --model large-v3 даёт лучшее качество на GPU (~2x дольше)",
|
||||
style="dim",
|
||||
)
|
||||
if info.device != info.resolved_device:
|
||||
console.print(
|
||||
f"Запрошено {info.requested_device}, используется {info.device}",
|
||||
style="yellow",
|
||||
)
|
||||
if verbose:
|
||||
threads = info.cpu_threads or "по умолчанию библиотеки"
|
||||
console.print(
|
||||
f"Движок: {info.engine} Потоки (запрошено): {threads}", style="dim"
|
||||
)
|
||||
for key, value in info.runtime.items():
|
||||
console.print(f" {key}: {value}", style="dim", markup=False)
|
||||
|
||||
|
||||
def _load_diarizer(
|
||||
diarize: bool, speakers: int | None, cpu_threads: int
|
||||
) -> SpeakerDiarizer | None:
|
||||
"""Готовит диаризатор до первого ASR; пословный контракт уже проверен module."""
|
||||
if not diarize:
|
||||
return None
|
||||
return load_speaker_diarizer(
|
||||
speakers=speakers,
|
||||
threads=cpu_threads,
|
||||
on_status=lambda message: console.print(message),
|
||||
)
|
||||
|
||||
|
||||
def _run_single(
|
||||
file: Path,
|
||||
defaults: ResolvedConfig,
|
||||
request: ExecutionRequest,
|
||||
output: Path | None,
|
||||
verbose: bool,
|
||||
compute_type_explicit: bool = False,
|
||||
cpu_threads: int = 0,
|
||||
diarize: bool = False,
|
||||
speakers: int | None = None,
|
||||
) -> None:
|
||||
@@ -435,9 +457,6 @@ def _run_single(
|
||||
start = time.monotonic()
|
||||
|
||||
validated_file = validate_input_file(file)
|
||||
requested_device = defaults["device"]
|
||||
resolved_device = detect_device(requested_device)
|
||||
strict = requested_device != "auto"
|
||||
output_path = build_output_path(validated_file, output)
|
||||
|
||||
console.print(f"Файл: [bold]{validated_file.name}[/bold]")
|
||||
@@ -445,59 +464,18 @@ def _run_single(
|
||||
def on_segment(seg: Segment) -> None:
|
||||
console.print(f" [{seg.start:.2f}s] {seg.text.strip()}")
|
||||
|
||||
model_obj, actual_device, backend, model_path = load_model(
|
||||
defaults["model"],
|
||||
resolved_device,
|
||||
defaults["compute_type"],
|
||||
on_status=lambda msg: console.print(msg),
|
||||
strict_device=strict,
|
||||
compute_type_explicit=compute_type_explicit,
|
||||
cpu_threads=cpu_threads,
|
||||
)
|
||||
actual_ct = (
|
||||
getattr(backend, "actual_compute_type", defaults["compute_type"])
|
||||
or defaults["compute_type"]
|
||||
)
|
||||
console.print(
|
||||
f"Модель: [bold]{defaults['model']}[/bold] "
|
||||
f"Устройство: [bold]{actual_device}[/bold] "
|
||||
f"Compute: [bold]{actual_ct}[/bold]"
|
||||
)
|
||||
if actual_device == "openvino-gpu" and defaults["model"] != "large-v3":
|
||||
console.print(
|
||||
"Совет: --model large-v3 даёт лучшее качество на GPU (~2x дольше)",
|
||||
style="dim",
|
||||
)
|
||||
|
||||
speaker_diarizer = None
|
||||
if diarize:
|
||||
if not backend.word_timestamps_available:
|
||||
raise ValueError(
|
||||
"Выбранный движок или модель не поддерживает пословные таймкоды"
|
||||
)
|
||||
speaker_diarizer = load_speaker_diarizer(
|
||||
speakers=speakers,
|
||||
threads=cpu_threads,
|
||||
on_status=lambda message: console.print(message),
|
||||
)
|
||||
transcriber = Transcriber(request)
|
||||
info = transcriber.prepare(on_status=lambda msg: console.print(msg))
|
||||
_print_execution_header(info, verbose)
|
||||
speaker_diarizer = _load_diarizer(diarize, speakers, request.cpu_threads)
|
||||
|
||||
with Status("Подготавливаю запуск...", console=console) as status:
|
||||
tfr = _transcribe_file(
|
||||
model=model_obj,
|
||||
actual_device=actual_device,
|
||||
backend=backend,
|
||||
model_path=model_path,
|
||||
file_path=validated_file,
|
||||
model_name=defaults["model"],
|
||||
compute_type=defaults["compute_type"],
|
||||
language=defaults["language"] if defaults["language"] != "auto" else None,
|
||||
result = transcriber.transcribe(
|
||||
validated_file,
|
||||
on_segment=on_segment if verbose else None,
|
||||
on_status=status.update,
|
||||
strict_device=strict,
|
||||
cpu_threads=cpu_threads,
|
||||
)
|
||||
|
||||
result = tfr.result
|
||||
speaker_transcript = None
|
||||
diarization_warning = None
|
||||
diarization_degraded = False
|
||||
@@ -524,33 +502,19 @@ def _run_single(
|
||||
if diarization_warning is not None:
|
||||
console.print(f"Внимание: {diarization_warning}", style="yellow")
|
||||
|
||||
if tfr.actual_device != resolved_device:
|
||||
if requested_device == "auto":
|
||||
console.print(
|
||||
f"Определено устройство {resolved_device}, "
|
||||
f"но использовано {tfr.actual_device} (fallback)",
|
||||
style="yellow",
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
f"Запрошено {requested_device}, использовано {tfr.actual_device}",
|
||||
style="yellow",
|
||||
)
|
||||
|
||||
if len(result.segments) == 0:
|
||||
message = f"Речь не обнаружена в файле {validated_file.name}"
|
||||
if speaker_diarizer is not None:
|
||||
message += "; диаризация не запускалась"
|
||||
console.print(message, style="yellow")
|
||||
|
||||
device_info = _format_device_info(result.device_used)
|
||||
language_mode = _format_language_mode(defaults["language"], result)
|
||||
language_mode = _format_language_mode(request.language or "auto", result)
|
||||
|
||||
content = format_transcript(
|
||||
result=result,
|
||||
source_filename=validated_file.name,
|
||||
model_name=defaults["model"],
|
||||
device_info=device_info,
|
||||
model_name=info.model,
|
||||
device_info=info.description,
|
||||
language_mode=language_mode,
|
||||
speaker_transcript=speaker_transcript,
|
||||
diarization_warning=diarization_warning,
|
||||
@@ -567,11 +531,9 @@ def _run_single(
|
||||
|
||||
def _run_batch(
|
||||
files: list[Path],
|
||||
defaults: ResolvedConfig,
|
||||
request: ExecutionRequest,
|
||||
verbose: bool,
|
||||
force: bool,
|
||||
compute_type_explicit: bool = False,
|
||||
cpu_threads: int = 0,
|
||||
diarize: bool = False,
|
||||
speakers: int | None = None,
|
||||
) -> None:
|
||||
@@ -601,50 +563,11 @@ def _run_batch(
|
||||
raise SystemExit(1)
|
||||
return
|
||||
|
||||
# Phase 2: Load model (ensure + create в одном вызове)
|
||||
requested_device = defaults["device"]
|
||||
resolved_device = detect_device(requested_device)
|
||||
strict = requested_device != "auto"
|
||||
model_obj, actual_device, backend, model_path = load_model(
|
||||
defaults["model"],
|
||||
resolved_device,
|
||||
defaults["compute_type"],
|
||||
on_status=lambda msg: console.print(msg),
|
||||
strict_device=strict,
|
||||
compute_type_explicit=compute_type_explicit,
|
||||
cpu_threads=cpu_threads,
|
||||
)
|
||||
|
||||
if actual_device == "openvino-gpu" and defaults["model"] != "large-v3":
|
||||
console.print(
|
||||
"Совет: --model large-v3 даёт лучшее качество на GPU (~2x дольше)",
|
||||
style="dim",
|
||||
)
|
||||
|
||||
if actual_device != resolved_device:
|
||||
if requested_device == "auto":
|
||||
console.print(
|
||||
f"Определено устройство {resolved_device}, "
|
||||
f"но используется {actual_device} (fallback)",
|
||||
style="yellow",
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
f"Запрошено {requested_device}, используется {actual_device}",
|
||||
style="yellow",
|
||||
)
|
||||
|
||||
speaker_diarizer = None
|
||||
if diarize:
|
||||
if not backend.word_timestamps_available:
|
||||
raise ValueError(
|
||||
"Выбранный движок или модель не поддерживает пословные таймкоды"
|
||||
)
|
||||
speaker_diarizer = load_speaker_diarizer(
|
||||
speakers=speakers,
|
||||
threads=cpu_threads,
|
||||
on_status=lambda message: console.print(message),
|
||||
)
|
||||
# Phase 2: module выполнения создаётся, когда есть работа
|
||||
transcriber = Transcriber(request)
|
||||
info = transcriber.prepare(on_status=lambda msg: console.print(msg))
|
||||
_print_execution_header(info, verbose)
|
||||
speaker_diarizer = _load_diarizer(diarize, speakers, request.cpu_threads)
|
||||
|
||||
# Phase 3: Transcribe
|
||||
processed = 0
|
||||
@@ -662,38 +585,15 @@ def _run_batch(
|
||||
console.print(f" [{seg.start:.2f}s] {seg.text.strip()}")
|
||||
|
||||
with Status(f"{prefix}...", console=console) as status:
|
||||
tfr = _transcribe_file(
|
||||
model=model_obj,
|
||||
actual_device=actual_device,
|
||||
backend=backend,
|
||||
model_path=model_path,
|
||||
file_path=file,
|
||||
model_name=defaults["model"],
|
||||
compute_type=defaults["compute_type"],
|
||||
language=defaults["language"]
|
||||
if defaults["language"] != "auto"
|
||||
else None,
|
||||
result = transcriber.transcribe(
|
||||
file,
|
||||
on_segment=on_segment if verbose else None,
|
||||
on_status=status.update
|
||||
if not verbose
|
||||
else lambda msg: console.print(msg),
|
||||
strict_device=strict,
|
||||
cpu_threads=cpu_threads,
|
||||
)
|
||||
|
||||
if tfr.actual_device != actual_device:
|
||||
console.print(
|
||||
f" {file.name}: fallback на {tfr.actual_device} при транскрипции",
|
||||
style="yellow",
|
||||
)
|
||||
# Обновляем после возможного mid-stream fallback
|
||||
model_obj = tfr.model
|
||||
actual_device = tfr.actual_device
|
||||
backend = tfr.backend
|
||||
model_path = tfr.model_path
|
||||
|
||||
result = tfr.result
|
||||
language_mode = _format_language_mode(defaults["language"], result)
|
||||
language_mode = _format_language_mode(request.language or "auto", result)
|
||||
speaker_transcript = None
|
||||
diarization_warning = None
|
||||
file_degraded = False
|
||||
@@ -733,13 +633,11 @@ def _run_batch(
|
||||
message += "; диаризация не запускалась"
|
||||
console.print(message, style="yellow")
|
||||
|
||||
device_info = _format_device_info(result.device_used)
|
||||
|
||||
content = format_transcript(
|
||||
result=result,
|
||||
source_filename=file.name,
|
||||
model_name=defaults["model"],
|
||||
device_info=device_info,
|
||||
model_name=info.model,
|
||||
device_info=info.description,
|
||||
language_mode=language_mode,
|
||||
speaker_transcript=speaker_transcript,
|
||||
diarization_warning=diarization_warning,
|
||||
@@ -758,6 +656,7 @@ def _run_batch(
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_print_cuda_hint(exc, request.device)
|
||||
if verbose:
|
||||
console.print_exception()
|
||||
else:
|
||||
|
||||
@@ -30,12 +30,16 @@ class CliValues(TypedDict, total=False):
|
||||
|
||||
|
||||
class ResolvedConfig(TypedDict):
|
||||
"""Полная конфигурация после разрешения каскада."""
|
||||
"""Конфигурация после каскада CLI > TOML.
|
||||
|
||||
model: str
|
||||
``model`` и ``compute_type`` остаются ``None``, если не заданы явно:
|
||||
их умолчания зависят от устройства и разрешаются при загрузке модели.
|
||||
"""
|
||||
|
||||
model: str | None
|
||||
language: str
|
||||
device: str
|
||||
compute_type: str
|
||||
compute_type: str | None
|
||||
diarize: bool
|
||||
|
||||
|
||||
@@ -47,6 +51,8 @@ HARDCODED_DEFAULTS: ResolvedConfig = {
|
||||
"diarize": False,
|
||||
}
|
||||
|
||||
# Умолчания по устройству; HARDCODED_DEFAULTS — последний резерв для
|
||||
# устройств вне таблицы.
|
||||
DEVICE_DEFAULTS: dict[str, dict[str, str]] = {
|
||||
"cuda": {"model": "medium", "compute_type": "float16"},
|
||||
"cpu": {"model": "medium", "compute_type": "float32"},
|
||||
@@ -136,11 +142,12 @@ def load_config(path: Path | None = None) -> ConfigValues:
|
||||
|
||||
|
||||
def resolve_defaults(cli_values: CliValues, config: ConfigValues) -> ResolvedConfig:
|
||||
"""Каскад приоритетов: CLI > конфиг-файл > hardcoded-дефолты."""
|
||||
"""Каскад приоритетов: CLI > конфиг-файл > hardcoded-дефолты.
|
||||
|
||||
Device-aware ключи (model, compute_type) без явного значения остаются None.
|
||||
"""
|
||||
return {
|
||||
"model": _resolve_value(
|
||||
cli_values.get("model"), config.get("model"), HARDCODED_DEFAULTS["model"]
|
||||
),
|
||||
"model": _resolve_value(cli_values.get("model"), config.get("model"), None),
|
||||
"language": _resolve_value(
|
||||
cli_values.get("language"),
|
||||
config.get("language"),
|
||||
@@ -152,9 +159,7 @@ def resolve_defaults(cli_values: CliValues, config: ConfigValues) -> ResolvedCon
|
||||
HARDCODED_DEFAULTS["device"],
|
||||
),
|
||||
"compute_type": _resolve_value(
|
||||
cli_values.get("compute_type"),
|
||||
config.get("compute_type"),
|
||||
HARDCODED_DEFAULTS["compute_type"],
|
||||
cli_values.get("compute_type"), config.get("compute_type"), None
|
||||
),
|
||||
"diarize": _resolve_value(
|
||||
cli_values.get("diarize"),
|
||||
@@ -171,23 +176,3 @@ def _resolve_value(cli_value: _T | None, config_value: _T | None, default: _T) -
|
||||
if config_value is not None:
|
||||
return config_value
|
||||
return default
|
||||
|
||||
|
||||
def apply_device_defaults(
|
||||
defaults: ResolvedConfig,
|
||||
resolved_device: str,
|
||||
cli_values: CliValues,
|
||||
config: ConfigValues,
|
||||
) -> ResolvedConfig:
|
||||
"""Применяет device-aware дефолты для model и compute_type,
|
||||
если они не были явно заданы через CLI или конфиг."""
|
||||
device_defs = DEVICE_DEFAULTS.get(resolved_device, {})
|
||||
if not device_defs:
|
||||
return defaults
|
||||
|
||||
result = dict(defaults)
|
||||
for key in ("model", "compute_type"):
|
||||
if cli_values.get(key) is None and key not in config:
|
||||
if key in device_defs:
|
||||
result[key] = device_defs[key]
|
||||
return cast(ResolvedConfig, result)
|
||||
@@ -2,170 +2,245 @@
|
||||
|
||||
import warnings
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from local_transcriber.backends import get_backend
|
||||
from local_transcriber.config import DEVICE_DEFAULTS, HARDCODED_DEFAULTS
|
||||
|
||||
# Re-export из types.py для обратной совместимости
|
||||
from local_transcriber.types import ( # noqa: F401
|
||||
from local_transcriber.types import (
|
||||
Segment,
|
||||
TranscribeFileResult,
|
||||
TranscribeResult,
|
||||
WordTimestampsUnavailableError,
|
||||
)
|
||||
from local_transcriber.utils import detect_device, get_gpu_name, get_intel_gpu_name
|
||||
|
||||
|
||||
def load_model(
|
||||
model_name: str,
|
||||
device: str,
|
||||
compute_type: str,
|
||||
on_status: Callable[[str], None] | None = None,
|
||||
strict_device: bool = False,
|
||||
compute_type_explicit: bool = False,
|
||||
cpu_threads: int = 0,
|
||||
) -> tuple[Any, str, Any, str]:
|
||||
"""Загружает модель: ensure + create с fallback.
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionRequest:
|
||||
"""Запрошенные настройки запуска до разрешения умолчаний.
|
||||
|
||||
Возвращает (model, actual_device, backend, model_path).
|
||||
compute_type_explicit: True если пользователь явно указал --compute-type.
|
||||
cpu_threads: число потоков для CPU inference (0 = дефолт библиотеки).
|
||||
``None`` в model/compute_type означает device-aware умолчание;
|
||||
``strict_device=None`` — strict для любого явного device, кроме ``auto``.
|
||||
"""
|
||||
backend = get_backend(device, compute_type_explicit=compute_type_explicit)
|
||||
actual_device = device
|
||||
|
||||
model_path = backend.ensure_model_available(model_name, compute_type, on_status)
|
||||
device: str = "auto"
|
||||
model: str | None = None
|
||||
compute_type: str | None = None
|
||||
language: str | None = None
|
||||
cpu_threads: int = 0
|
||||
strict_device: bool | None = None
|
||||
require_word_timestamps: bool = False
|
||||
|
||||
try:
|
||||
_notify_status(on_status, f"Инициализирую модель на {device}...")
|
||||
model = backend.create_model(
|
||||
model_path, device, compute_type, cpu_threads=cpu_threads
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionInfo:
|
||||
"""Сведения о выполнении: что запрошено, что выбрано и чем выполняется."""
|
||||
|
||||
requested_device: str
|
||||
resolved_device: str # выбор до загрузки; отличается от device после fallback
|
||||
device: str
|
||||
engine: str
|
||||
model: str
|
||||
compute_type: str
|
||||
cpu_threads: int
|
||||
word_timestamps_available: bool
|
||||
description: str # строка исполнения для шапки транскрипта
|
||||
runtime: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
class Transcriber:
|
||||
"""Module выполнения распознавания на один запуск.
|
||||
|
||||
Владеет загруженной моделью, adapter'ом, фактическими настройками
|
||||
и разрешёнными переходами исполнения. Caller передаёт файлы по одному
|
||||
и получает результат; состояние между файлами не переносит.
|
||||
"""
|
||||
|
||||
def __init__(self, request: ExecutionRequest) -> None:
|
||||
self._request = request
|
||||
self._strict = (
|
||||
request.strict_device
|
||||
if request.strict_device is not None
|
||||
else request.device != "auto"
|
||||
)
|
||||
# Резолвим actual_device по реальному OpenVINO device
|
||||
ov_dev = getattr(backend, "actual_ov_device", None)
|
||||
if ov_dev == "GPU" and actual_device != "openvino-gpu":
|
||||
actual_device = "openvino-gpu"
|
||||
elif (
|
||||
ov_dev == "CPU"
|
||||
and actual_device.startswith("openvino")
|
||||
and actual_device != "openvino-cpu"
|
||||
):
|
||||
actual_device = "openvino-cpu"
|
||||
self._compute_type_explicit = request.compute_type is not None
|
||||
self._resolved_device = detect_device(request.device)
|
||||
self._device = self._resolved_device
|
||||
self._backend: Any = None
|
||||
self._model: Any = None
|
||||
self._info: ExecutionInfo | None = None
|
||||
|
||||
@property
|
||||
def execution(self) -> ExecutionInfo:
|
||||
"""Текущие сведения о выполнении; доступны после prepare()."""
|
||||
if self._info is None:
|
||||
raise RuntimeError("Модель ещё не подготовлена: вызовите prepare()")
|
||||
return self._info
|
||||
|
||||
def prepare(self, on_status: Callable[[str], None] | None = None) -> ExecutionInfo:
|
||||
"""Загружает модель и проверяет возможности до первого файла. Идемпотентно."""
|
||||
if self._info is not None:
|
||||
return self._info
|
||||
try:
|
||||
info = self._load(self._device, on_status)
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
if device != "cpu" and _is_backend_error(exc, device):
|
||||
if strict_device:
|
||||
if not self._may_fall_back(exc):
|
||||
raise
|
||||
warnings.warn(
|
||||
f"Не удалось загрузить модель на {device}: {exc}. Переключение на CPU.",
|
||||
f"Не удалось загрузить модель на {self._device}: {exc}. "
|
||||
"Переключение на CPU.",
|
||||
stacklevel=2,
|
||||
)
|
||||
actual_device = "cpu"
|
||||
backend = get_backend("cpu")
|
||||
model_path = backend.ensure_model_available(
|
||||
model_name, compute_type, on_status
|
||||
info = self._load("cpu", on_status)
|
||||
if self._request.require_word_timestamps and not info.word_timestamps_available:
|
||||
raise ValueError(
|
||||
"Выбранный движок или модель не поддерживает пословные таймкоды"
|
||||
)
|
||||
_notify_status(on_status, "Инициализирую модель на cpu...")
|
||||
model = backend.create_model(
|
||||
model_path, "cpu", compute_type, cpu_threads=cpu_threads
|
||||
)
|
||||
else:
|
||||
raise
|
||||
self._info = info
|
||||
return info
|
||||
|
||||
return model, actual_device, backend, model_path
|
||||
|
||||
|
||||
def _transcribe_file(
|
||||
model: Any,
|
||||
actual_device: str,
|
||||
backend: Any,
|
||||
model_path: str,
|
||||
def transcribe(
|
||||
self,
|
||||
file_path: Path,
|
||||
model_name: str,
|
||||
compute_type: str,
|
||||
language: str | None = None,
|
||||
on_segment: Callable[[Segment], None] | None = None,
|
||||
on_status: Callable[[str], None] | None = None,
|
||||
strict_device: bool = False,
|
||||
cpu_threads: int = 0,
|
||||
) -> TranscribeFileResult:
|
||||
"""Транскрибирует один файл. При mid-stream fallback перезагружает модель."""
|
||||
lang_arg = language if language and language != "auto" else None
|
||||
|
||||
) -> TranscribeResult:
|
||||
"""Транскрибирует один файл; при разрешённом fallback перезагружает модель."""
|
||||
self.prepare(on_status)
|
||||
lang_arg = self._request.language or None
|
||||
if lang_arg == "auto":
|
||||
lang_arg = None
|
||||
try:
|
||||
_notify_status(on_status, "Транскрибирую...")
|
||||
result = backend.transcribe(model, file_path, lang_arg, on_segment, on_status)
|
||||
result.device_used = actual_device
|
||||
result = self._recognize(file_path, lang_arg, on_segment, on_status)
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
if actual_device != "cpu" and _is_backend_error(exc, actual_device):
|
||||
if strict_device:
|
||||
if not self._may_fall_back(exc):
|
||||
raise
|
||||
warnings.warn(
|
||||
f"Ошибка при транскрипции на {actual_device}: {exc}. "
|
||||
f"Ошибка при транскрипции на {self._device}: {exc}. "
|
||||
"Переключение на CPU и повтор.",
|
||||
stacklevel=2,
|
||||
)
|
||||
actual_device = "cpu"
|
||||
backend = get_backend("cpu")
|
||||
model_path = backend.ensure_model_available(
|
||||
model_name, compute_type, on_status
|
||||
)
|
||||
_notify_status(on_status, "Инициализирую модель на cpu...")
|
||||
model = backend.create_model(
|
||||
model_path, "cpu", compute_type, cpu_threads=cpu_threads
|
||||
)
|
||||
_notify_status(on_status, "Транскрибирую...")
|
||||
result = backend.transcribe(
|
||||
model, file_path, lang_arg, on_segment, on_status
|
||||
)
|
||||
result.device_used = actual_device
|
||||
else:
|
||||
raise
|
||||
self._info = self._load("cpu", on_status)
|
||||
result = self._recognize(file_path, lang_arg, on_segment, on_status)
|
||||
return result
|
||||
|
||||
return TranscribeFileResult(
|
||||
result=result,
|
||||
model=model,
|
||||
actual_device=actual_device,
|
||||
backend=backend,
|
||||
model_path=model_path,
|
||||
def _recognize(
|
||||
self,
|
||||
file_path: Path,
|
||||
language: str | None,
|
||||
on_segment: Callable[[Segment], None] | None,
|
||||
on_status: Callable[[str], None] | None,
|
||||
) -> TranscribeResult:
|
||||
_notify_status(on_status, "Транскрибирую...")
|
||||
result = self._backend.transcribe(
|
||||
self._model, file_path, language, on_segment, on_status
|
||||
)
|
||||
result.device_used = self._device
|
||||
return result
|
||||
|
||||
def _may_fall_back(self, exc: BaseException) -> bool:
|
||||
"""Разрешённый переход: не strict, не CPU и ошибка самого движка."""
|
||||
if self._strict or self._device == "cpu":
|
||||
return False
|
||||
return _is_backend_error(exc, self._device)
|
||||
|
||||
def _load(
|
||||
self, device: str, on_status: Callable[[str], None] | None
|
||||
) -> ExecutionInfo:
|
||||
"""Загружает модель на device; неявные model/compute_type берутся из его умолчаний."""
|
||||
defaults = DEVICE_DEFAULTS.get(device, {})
|
||||
model_name = self._request.model or defaults.get(
|
||||
"model", HARDCODED_DEFAULTS["model"]
|
||||
)
|
||||
compute_type = self._request.compute_type or defaults.get(
|
||||
"compute_type", HARDCODED_DEFAULTS["compute_type"]
|
||||
)
|
||||
backend = get_backend(device, compute_type_explicit=self._compute_type_explicit)
|
||||
model_path = backend.ensure_model_available(model_name, compute_type, on_status)
|
||||
_notify_status(on_status, f"Инициализирую модель на {device}...")
|
||||
model = backend.create_model(
|
||||
model_path,
|
||||
device,
|
||||
compute_type,
|
||||
cpu_threads=self._request.cpu_threads,
|
||||
)
|
||||
self._backend = backend
|
||||
self._model = model
|
||||
self._device = _refine_openvino_device(device, backend)
|
||||
return ExecutionInfo(
|
||||
requested_device=self._request.device,
|
||||
resolved_device=self._resolved_device,
|
||||
device=self._device,
|
||||
engine=backend.engine,
|
||||
model=model_name,
|
||||
compute_type=getattr(backend, "actual_compute_type", None) or compute_type,
|
||||
cpu_threads=self._request.cpu_threads,
|
||||
word_timestamps_available=bool(backend.word_timestamps_available),
|
||||
description=_describe_device(self._device),
|
||||
runtime=_collect_runtime_info(backend),
|
||||
)
|
||||
|
||||
|
||||
def _collect_runtime_info(backend: Any) -> dict[str, str]:
|
||||
"""Диагностика не должна ронять запуск: сбой попадает в сведения строкой."""
|
||||
try:
|
||||
return dict(backend.runtime_info())
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"diagnostics": f"недоступно ({exc})"}
|
||||
|
||||
|
||||
def _refine_openvino_device(device: str, backend: Any) -> str:
|
||||
"""Заменяет запрошенный openvino-* на устройство, которое реально выбрал OpenVINO."""
|
||||
ov_dev = getattr(backend, "actual_ov_device", None)
|
||||
if ov_dev == "GPU" and device != "openvino-gpu":
|
||||
return "openvino-gpu"
|
||||
if ov_dev == "CPU" and device.startswith("openvino") and device != "openvino-cpu":
|
||||
return "openvino-cpu"
|
||||
return device
|
||||
|
||||
|
||||
def _describe_device(device: str) -> str:
|
||||
"""Строка исполнения для шапки транскрипта; GPU называется только по данным драйвера."""
|
||||
if device == "cuda":
|
||||
return f"CUDA ({get_gpu_name() or 'Unknown GPU'})"
|
||||
if device == "openvino-gpu":
|
||||
return f"OpenVINO ({get_intel_gpu_name() or 'Intel GPU'})"
|
||||
if device == "openvino-cpu":
|
||||
return "OpenVINO (CPU)"
|
||||
if device == "onnx":
|
||||
return "ONNX (CPU)"
|
||||
return "CPU"
|
||||
|
||||
|
||||
def transcribe(
|
||||
file_path: Path,
|
||||
model_name: str = "large-v3",
|
||||
model_name: str | None = None,
|
||||
device: str = "auto",
|
||||
compute_type: str = "int8",
|
||||
compute_type: str | None = None,
|
||||
language: str | None = None,
|
||||
on_segment: Callable[[Segment], None] | None = None,
|
||||
on_status: Callable[[str], None] | None = None,
|
||||
strict_device: bool = False,
|
||||
cpu_threads: int = 0,
|
||||
) -> TranscribeResult:
|
||||
"""High-level API: загрузка модели + транскрипция за один вызов."""
|
||||
model, actual_device, backend, model_path = load_model(
|
||||
model_name,
|
||||
device,
|
||||
compute_type,
|
||||
on_status,
|
||||
strict_device,
|
||||
compute_type_explicit=True, # Python API — caller explicitly chose compute_type
|
||||
"""Публичный путь одного файла через тот же module выполнения, что и CLI.
|
||||
|
||||
``None`` в model_name/compute_type берёт умолчания выбранного исполнения.
|
||||
"""
|
||||
run = Transcriber(
|
||||
ExecutionRequest(
|
||||
device=device,
|
||||
model=model_name,
|
||||
compute_type=compute_type,
|
||||
language=language,
|
||||
cpu_threads=cpu_threads,
|
||||
strict_device=strict_device,
|
||||
)
|
||||
tfr = _transcribe_file(
|
||||
model,
|
||||
actual_device,
|
||||
backend,
|
||||
model_path,
|
||||
file_path,
|
||||
model_name,
|
||||
compute_type,
|
||||
language,
|
||||
on_segment,
|
||||
on_status,
|
||||
strict_device,
|
||||
cpu_threads=cpu_threads,
|
||||
)
|
||||
return tfr.result
|
||||
return run.transcribe(file_path, on_segment, on_status)
|
||||
|
||||
|
||||
def ensure_model_available(
|
||||
@@ -175,8 +250,6 @@ def ensure_model_available(
|
||||
on_status: Callable[[str], None] | None = None,
|
||||
) -> str:
|
||||
"""Публичный helper: гарантирует наличие модели для указанного бэкенда."""
|
||||
from local_transcriber.config import DEVICE_DEFAULTS, HARDCODED_DEFAULTS
|
||||
|
||||
if compute_type is None:
|
||||
device_defs = DEVICE_DEFAULTS.get(device, {})
|
||||
compute_type = device_defs.get(
|
||||
@@ -190,11 +263,60 @@ def ensure_model_available(
|
||||
|
||||
|
||||
def _is_cuda_error(exc: BaseException) -> bool:
|
||||
"""Проверка CUDA ошибок — используется в cli.py для Windows-диагностики."""
|
||||
"""Проверяет, относится ли ошибка к CUDA-бэкенду."""
|
||||
msg = str(exc).lower()
|
||||
return any(k in msg for k in ("cuda", "cublas", "cudnn", "out of memory"))
|
||||
|
||||
|
||||
def cuda_error_hint(exc: BaseException, *, device: str | None = None) -> str | None:
|
||||
"""Подсказывает действие только для распознанной причины CUDA-ошибки."""
|
||||
msg = str(exc).lower()
|
||||
if device == "cuda" and "requested " in msg and "compute type" in msg and (
|
||||
"do not support efficient" in msg
|
||||
):
|
||||
return (
|
||||
"Тип вычислений несовместим с выбранным GPU/CUDA runtime. "
|
||||
"Установка extra не добавит аппаратную поддержку. Выберите "
|
||||
"поддерживаемый --compute-type или --device onnx / --device cpu."
|
||||
)
|
||||
if any(k in msg for k in (
|
||||
"no kernel image", "invalid device function", "unsupported gpu",
|
||||
"cublas_status_arch_mismatch", "cuda_error_no_binary_for_gpu",
|
||||
)):
|
||||
return (
|
||||
"GPU несовместим с выбранным CUDA runtime. Установка extra не исправит "
|
||||
"аппаратную несовместимость. Используйте --device onnx или --device cpu."
|
||||
)
|
||||
if any(k in msg for k in (
|
||||
"driver version is insufficient", "no cuda-capable device",
|
||||
"cuda error: no device", "cuda_error_no_device", "libcuda.so", "nvcuda.dll",
|
||||
)):
|
||||
return (
|
||||
"CUDA не видит совместимого GPU/драйвера. Проверьте драйвер NVIDIA "
|
||||
"и совместимость устройства с runtime. Extra не устанавливает драйвер."
|
||||
)
|
||||
if any(k in msg for k in ("cublas", "cudnn", "cudart")) and any(
|
||||
k in msg for k in (
|
||||
"not found", "cannot be loaded", "could not load", "could not find",
|
||||
"cannot open shared object", "no such file", "winerror 126",
|
||||
)
|
||||
):
|
||||
return (
|
||||
"Не найдены CUDA-библиотеки или их зависимости. Подключите extra cuda "
|
||||
"в окружении приложения:\n"
|
||||
" Из клона: uv tool install --force '.[cuda]'\n"
|
||||
" Для разработки: uv sync --extra cuda\n"
|
||||
"При установке из Git используйте инструкцию CUDA в README. "
|
||||
"На Windows также нужен Visual C++ Runtime x64."
|
||||
)
|
||||
if "out of memory" in msg and any(k in msg for k in ("cuda", "cublas", "cudnn")):
|
||||
return (
|
||||
"Недостаточно памяти для выбранного CUDA-пути. Выберите меньшую модель "
|
||||
"или явно используйте --device onnx / --device cpu."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _is_backend_error(exc: BaseException, device: str) -> bool:
|
||||
"""Определяет, связана ли ошибка с конкретным бэкендом (а не с пользовательскими данными)."""
|
||||
if isinstance(exc, WordTimestampsUnavailableError):
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
# Единый признак «язык неизвестен» для всех бэкендов
|
||||
UNKNOWN_LANGUAGE = "unknown"
|
||||
@@ -83,14 +82,5 @@ class TranscribeResult:
|
||||
words: list[Word] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranscribeFileResult:
|
||||
result: TranscribeResult
|
||||
model: Any # backend-specific model handle
|
||||
actual_device: str
|
||||
backend: Any = None # backend instance (для переиспользования в батче)
|
||||
model_path: str = "" # путь к модели (меняется при cross-backend fallback)
|
||||
|
||||
|
||||
StatusCallback = Callable[[str], None] | None
|
||||
SegmentCallback = Callable[[Segment], None] | None
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import glob
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import warnings
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from pathlib import Path
|
||||
|
||||
SUPPORTED_EXTENSIONS = {
|
||||
@@ -16,15 +16,13 @@ SUPPORTED_EXTENSIONS = {
|
||||
def detect_device(requested: str = "auto") -> str:
|
||||
"""Определяет устройство для вычислений.
|
||||
|
||||
При ``requested="auto"`` выбирает CUDA при наличии ``nvidia-smi``,
|
||||
иначе ONNX на CPU.
|
||||
При ``requested="auto"`` всегда выбирает ONNX на CPU.
|
||||
CUDA включается только явным выбором пользователя.
|
||||
``"openvino"`` — алиас для авто-детекта внутри OpenVINO
|
||||
(GPU если доступен, иначе CPU).
|
||||
Возвращает всегда конкретное значение (не абстрактный ``"openvino"``).
|
||||
"""
|
||||
if requested == "auto":
|
||||
if shutil.which("nvidia-smi") is not None:
|
||||
return "cuda"
|
||||
return "onnx"
|
||||
if requested == "openvino":
|
||||
if _is_openvino_gpu_available():
|
||||
@@ -144,3 +142,11 @@ def expand_globs(paths: list[Path]) -> list[Path]:
|
||||
def has_existing_transcript(input_path: Path) -> bool:
|
||||
"""Проверяет наличие транскрипта для skip-логики батч-режима."""
|
||||
return build_output_path(input_path).exists()
|
||||
|
||||
|
||||
def package_version(name: str) -> str:
|
||||
"""Версия установленного пакета для диагностики; отсутствие не считается ошибкой."""
|
||||
try:
|
||||
return version(name)
|
||||
except PackageNotFoundError:
|
||||
return "не установлен"
|
||||
@@ -7,6 +7,25 @@ from local_transcriber.backends.faster_whisper import FasterWhisperBackend
|
||||
from local_transcriber.types import Word
|
||||
|
||||
|
||||
@pytest.mark.parametrize("device", ["cpu", "cuda"])
|
||||
def test_bootstrap_only_for_explicit_cuda(monkeypatch, device):
|
||||
"""CPU-модель не загружает optional CUDA-библиотеки."""
|
||||
bootstrap = MagicMock()
|
||||
model_factory = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"local_transcriber.backends.faster_whisper.ensure_cublas_loadable", bootstrap
|
||||
)
|
||||
monkeypatch.setattr("faster_whisper.WhisperModel", model_factory)
|
||||
|
||||
model = FasterWhisperBackend().create_model("/model", device, "float32")
|
||||
|
||||
assert model is model_factory.return_value
|
||||
assert bootstrap.call_count == (1 if device == "cuda" else 0)
|
||||
model_factory.assert_called_once_with(
|
||||
"/model", device=device, compute_type="float32", cpu_threads=0
|
||||
)
|
||||
|
||||
|
||||
def test_transcribe_returns_canonical_words(tmp_path):
|
||||
audio = tmp_path / "audio.wav"
|
||||
raw_word = SimpleNamespace(start=0.2, end=0.7, word=" Привет")
|
||||
@@ -64,3 +83,18 @@ def test_transcribe_rejects_one_nonempty_segment_without_word_timestamps(tmp_pat
|
||||
|
||||
with pytest.raises(RuntimeError, match="пословные таймкоды"):
|
||||
FasterWhisperBackend().transcribe(model, tmp_path / "audio.wav", "ru")
|
||||
|
||||
|
||||
def test_runtime_info_reports_versions_and_cuda_devices(monkeypatch):
|
||||
"""Диагностика называет версии runtime и число CUDA-устройств, не обещая ускорения."""
|
||||
monkeypatch.setattr("ctranslate2.get_cuda_device_count", lambda: 0)
|
||||
backend = FasterWhisperBackend()
|
||||
backend.actual_compute_type = "float32"
|
||||
|
||||
info = backend.runtime_info()
|
||||
|
||||
assert info["ctranslate2"]
|
||||
assert info["faster_whisper"]
|
||||
assert info["cuda_devices"] == "0"
|
||||
assert info["compute_type"] == "float32"
|
||||
assert info["cpu_threads"] == "по умолчанию"
|
||||
@@ -463,3 +463,21 @@ def test_validate_model_dir_requires_alignment_heads_for_word_timestamps(tmp_pat
|
||||
|
||||
with pytest.raises(ValueError, match="alignment_heads"):
|
||||
_validate_model_dir(tmp_path)
|
||||
|
||||
|
||||
def test_runtime_info_reports_available_and_actual_devices(monkeypatch):
|
||||
"""Диагностика показывает устройства OpenVINO и то, что выбрано фактически."""
|
||||
core = MagicMock()
|
||||
core.available_devices = ["CPU", "GPU"]
|
||||
monkeypatch.setattr("openvino.Core", lambda: core)
|
||||
backend = OpenVINOBackend(ov_device="openvino-gpu", compute_type_explicit=True)
|
||||
backend.actual_ov_device = "GPU"
|
||||
backend.actual_compute_type = "fp16"
|
||||
|
||||
info = backend.runtime_info()
|
||||
|
||||
assert info["openvino"]
|
||||
assert info["available_devices"] == "CPU, GPU"
|
||||
assert info["device"] == "GPU"
|
||||
assert info["compute_type"] == "fp16"
|
||||
assert "--threads не применяется" in info["threads"]
|
||||
+358
-893
File diff suppressed because it is too large.
Load diff
+3
-76
@@ -3,7 +3,6 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from local_transcriber.config import (
|
||||
apply_device_defaults,
|
||||
find_config_file,
|
||||
load_config,
|
||||
resolve_defaults,
|
||||
@@ -107,15 +106,15 @@ def test_resolve_defaults_config_wins():
|
||||
assert result["model"] == "tiny"
|
||||
|
||||
|
||||
def test_resolve_defaults_hardcoded_fallback():
|
||||
def test_resolve_defaults_leaves_device_aware_keys_empty():
|
||||
result = resolve_defaults(
|
||||
{"model": None, "language": None, "device": None, "compute_type": None}, {}
|
||||
)
|
||||
assert result == {
|
||||
"model": "medium",
|
||||
"model": None,
|
||||
"language": "ru",
|
||||
"device": "auto",
|
||||
"compute_type": "float32",
|
||||
"compute_type": None,
|
||||
"diarize": False,
|
||||
}
|
||||
|
||||
@@ -136,54 +135,6 @@ def test_resolve_defaults_diarize_priority(cli_value, config_value, expected):
|
||||
assert resolve_defaults(cli, config)["diarize"] is expected
|
||||
|
||||
|
||||
def test_apply_device_defaults_cuda():
|
||||
defaults = {"model": "medium", "language": "ru", "device": "auto", "compute_type": "float32"}
|
||||
cli = {"model": None, "language": None, "device": None, "compute_type": None}
|
||||
result = apply_device_defaults(defaults, "cuda", cli, {})
|
||||
assert result["model"] == "medium"
|
||||
assert result["compute_type"] == "float16"
|
||||
|
||||
|
||||
def test_apply_device_defaults_cpu():
|
||||
defaults = {"model": "medium", "language": "ru", "device": "auto", "compute_type": "float32"}
|
||||
cli = {"model": None, "language": None, "device": None, "compute_type": None}
|
||||
result = apply_device_defaults(defaults, "cpu", cli, {})
|
||||
assert result["model"] == "medium"
|
||||
assert result["compute_type"] == "float32"
|
||||
|
||||
|
||||
def test_apply_device_defaults_onnx_uses_readable_model():
|
||||
defaults = {
|
||||
"model": "medium",
|
||||
"language": "ru",
|
||||
"device": "auto",
|
||||
"compute_type": "float32",
|
||||
}
|
||||
cli = {"model": None, "language": None, "device": None, "compute_type": None}
|
||||
|
||||
result = apply_device_defaults(defaults, "onnx", cli, {})
|
||||
|
||||
assert result["model"] == "gigaam-v3-e2e-rnnt"
|
||||
assert result["compute_type"] == "int8"
|
||||
|
||||
|
||||
def test_apply_device_defaults_cli_overrides():
|
||||
defaults = {"model": "large-v3", "language": "ru", "device": "auto", "compute_type": "int8"}
|
||||
cli = {"model": "large-v3", "language": None, "device": None, "compute_type": "int8"}
|
||||
result = apply_device_defaults(defaults, "cuda", cli, {})
|
||||
assert result["model"] == "large-v3"
|
||||
assert result["compute_type"] == "int8"
|
||||
|
||||
|
||||
def test_apply_device_defaults_config_overrides():
|
||||
defaults = {"model": "small", "language": "ru", "device": "auto", "compute_type": "int8"}
|
||||
cli = {"model": None, "language": None, "device": None, "compute_type": None}
|
||||
config = {"model": "small", "compute_type": "int8"}
|
||||
result = apply_device_defaults(defaults, "cuda", cli, config)
|
||||
assert result["model"] == "small"
|
||||
assert result["compute_type"] == "int8"
|
||||
|
||||
|
||||
def test_load_config_openvino_device(tmp_path):
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text('device = "openvino"\n')
|
||||
@@ -203,27 +154,3 @@ def test_load_config_openvino_cpu_device(tmp_path):
|
||||
config.write_text('device = "openvino-cpu"\n')
|
||||
result = load_config(config)
|
||||
assert result == {"device": "openvino-cpu"}
|
||||
|
||||
|
||||
def test_apply_device_defaults_openvino():
|
||||
defaults = {"model": "medium", "language": "ru", "device": "auto", "compute_type": "float32"}
|
||||
cli = {"model": None, "language": None, "device": None, "compute_type": None}
|
||||
result = apply_device_defaults(defaults, "openvino", cli, {})
|
||||
assert result["model"] == "medium"
|
||||
assert result["compute_type"] == "int8"
|
||||
|
||||
|
||||
def test_apply_device_defaults_openvino_gpu():
|
||||
defaults = {"model": "medium", "language": "ru", "device": "auto", "compute_type": "float32"}
|
||||
cli = {"model": None, "language": None, "device": None, "compute_type": None}
|
||||
result = apply_device_defaults(defaults, "openvino-gpu", cli, {})
|
||||
assert result["model"] == "medium"
|
||||
assert result["compute_type"] == "int8"
|
||||
|
||||
|
||||
def test_apply_device_defaults_openvino_cpu():
|
||||
defaults = {"model": "medium", "language": "ru", "device": "auto", "compute_type": "float32"}
|
||||
cli = {"model": None, "language": None, "device": None, "compute_type": None}
|
||||
result = apply_device_defaults(defaults, "openvino-cpu", cli, {})
|
||||
assert result["model"] == "medium"
|
||||
assert result["compute_type"] == "int8"
|
||||
@@ -48,12 +48,73 @@ def test_ensure_cublas_loads_library(monkeypatch, tmp_path):
|
||||
assert calls[0][1] == ctypes.RTLD_GLOBAL
|
||||
|
||||
|
||||
def test_ensure_cublas_skips_non_linux(monkeypatch):
|
||||
"""На не-Linux платформах -- no-op."""
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
def test_ensure_cublas_skips_macos(monkeypatch):
|
||||
"""На macOS bootstrap не загружает CUDA."""
|
||||
monkeypatch.setattr(sys, "platform", "darwin")
|
||||
ensure_cublas_loadable() # не должно бросать исключений
|
||||
|
||||
|
||||
def test_windows_optional_package_absent(monkeypatch):
|
||||
"""Windows работает без extra и не меняет поиск DLL."""
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
monkeypatch.setitem(sys.modules, "nvidia.cublas", None)
|
||||
ensure_cublas_loadable()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("broken", [False, True])
|
||||
def test_windows_preloads_dlls_and_keeps_handles(monkeypatch, tmp_path, broken):
|
||||
"""DLL загружаются по порядку один раз, ошибки не скрываются."""
|
||||
from local_transcriber import _cuda_bootstrap as bootstrap
|
||||
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir()
|
||||
nvidia = types.ModuleType("nvidia")
|
||||
nvidia.__path__ = []
|
||||
cublas = types.ModuleType("nvidia.cublas")
|
||||
cublas.__path__ = [str(tmp_path)]
|
||||
nvidia.cublas = cublas
|
||||
monkeypatch.setitem(sys.modules, "nvidia", nvidia)
|
||||
monkeypatch.setitem(sys.modules, "nvidia.cublas", cublas)
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
monkeypatch.setattr(bootstrap, "_dll_directories", {})
|
||||
monkeypatch.setattr(bootstrap, "_dll_libraries", {})
|
||||
calls = []
|
||||
directory_handle = object()
|
||||
library_handle = object()
|
||||
failure = OSError("cublas64_12.dll: dependency not found")
|
||||
|
||||
def add_directory(path):
|
||||
calls.append(("directory", path))
|
||||
return directory_handle
|
||||
|
||||
def load_library(path):
|
||||
calls.append(("load", path))
|
||||
if broken:
|
||||
raise failure
|
||||
return library_handle
|
||||
|
||||
monkeypatch.setattr(os, "add_dll_directory", add_directory, raising=False)
|
||||
monkeypatch.setattr(ctypes, "WinDLL", load_library, raising=False)
|
||||
original_path = os.environ.get("PATH")
|
||||
|
||||
if broken:
|
||||
with pytest.raises(OSError) as caught:
|
||||
ensure_cublas_loadable()
|
||||
assert caught.value is failure
|
||||
assert bootstrap._dll_libraries == {}
|
||||
else:
|
||||
ensure_cublas_loadable()
|
||||
ensure_cublas_loadable()
|
||||
assert calls == [
|
||||
("directory", str(bin_dir)),
|
||||
("load", str(bin_dir / "cublasLt64_12.dll")),
|
||||
("load", str(bin_dir / "cublas64_12.dll")),
|
||||
]
|
||||
assert list(bootstrap._dll_libraries.values()) == [library_handle] * 2
|
||||
assert bootstrap._dll_directories[str(bin_dir)] is directory_handle
|
||||
assert os.environ.get("PATH") == original_path
|
||||
|
||||
|
||||
def _nvidia_cublas_installed() -> bool:
|
||||
"""Проверяет, что pip-пакет nvidia-cublas-cu12 установлен."""
|
||||
try:
|
||||
|
||||
+78
-3
@@ -1,6 +1,7 @@
|
||||
"""Tests for onnx-asr backend."""
|
||||
|
||||
import warnings
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -64,12 +65,12 @@ class TestEnsureModelAvailable:
|
||||
def with_timestamps(self):
|
||||
return self
|
||||
|
||||
def fake_load_model(*, model, quantization):
|
||||
def fake_load_model(*, model, quantization, providers):
|
||||
quantizations.append(quantization)
|
||||
return FakeAsrAdapter()
|
||||
|
||||
monkeypatch.setattr("onnx_asr.load_model", fake_load_model)
|
||||
monkeypatch.setattr("onnx_asr.load_vad", lambda model: None)
|
||||
monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kwargs: None)
|
||||
|
||||
backend = OnnxAsrBackend(compute_type_explicit=False)
|
||||
model_id = backend.ensure_model_available(
|
||||
@@ -87,6 +88,56 @@ class TestEnsureModelAvailable:
|
||||
|
||||
|
||||
class TestCreateModel:
|
||||
def test_cpu_provider_is_explicit_for_asr_and_vad(self, monkeypatch):
|
||||
"""Доступность CoreML/CUDA не меняет исполнение ASR и VAD."""
|
||||
monkeypatch.setattr(
|
||||
"onnxruntime.get_available_providers",
|
||||
lambda: ["CoreMLExecutionProvider", "CUDAExecutionProvider", "CPUExecutionProvider"],
|
||||
)
|
||||
load_asr = MagicMock()
|
||||
load_vad = MagicMock()
|
||||
monkeypatch.setattr("onnx_asr.load_model", load_asr)
|
||||
monkeypatch.setattr("onnx_asr.load_vad", load_vad)
|
||||
|
||||
OnnxAsrBackend().create_model("gigaam-v3-e2e-rnnt", "onnx", "int8")
|
||||
|
||||
load_asr.assert_called_once_with(
|
||||
model="gigaam-v3-e2e-rnnt",
|
||||
quantization="int8",
|
||||
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):
|
||||
timestamped_model = object()
|
||||
|
||||
@@ -99,7 +150,7 @@ class TestCreateModel:
|
||||
return FakeVadAdapter()
|
||||
|
||||
monkeypatch.setattr("onnx_asr.load_model", lambda **kwargs: FakeAsrAdapter())
|
||||
monkeypatch.setattr("onnx_asr.load_vad", lambda model: object())
|
||||
monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kwargs: object())
|
||||
|
||||
model = OnnxAsrBackend().create_model("gigaam-v3-e2e-rnnt", "onnx", "int8")
|
||||
|
||||
@@ -127,6 +178,7 @@ class TestCreateModel:
|
||||
return self
|
||||
|
||||
monkeypatch.setattr("onnx_asr.load_model", fake_load_model)
|
||||
monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kwargs: None)
|
||||
|
||||
backend = OnnxAsrBackend()
|
||||
backend.actual_compute_type = "int8"
|
||||
@@ -637,3 +689,26 @@ class TestModelAliases:
|
||||
assert "--device cpu --model medium" in message
|
||||
assert "--device cuda --model large-v3-turbo" not in message
|
||||
assert "--device cpu --model large-v3-turbo" not in message
|
||||
|
||||
|
||||
class TestRuntimeInfo:
|
||||
def test_reports_versions_and_configured_providers(self, monkeypatch):
|
||||
"""Диагностика различает доступные providers и те, что заданы сессиям ASR и VAD."""
|
||||
monkeypatch.setattr(
|
||||
"onnxruntime.get_available_providers",
|
||||
lambda: ["CoreMLExecutionProvider", "CPUExecutionProvider"],
|
||||
)
|
||||
monkeypatch.setattr("onnx_asr.load_model", lambda **kwargs: MagicMock())
|
||||
monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kwargs: MagicMock())
|
||||
backend = OnnxAsrBackend()
|
||||
backend.actual_compute_type = "int8"
|
||||
backend.create_model("gigaam-v3-e2e-rnnt", "onnx", "int8")
|
||||
|
||||
info = backend.runtime_info()
|
||||
|
||||
assert info["onnxruntime"]
|
||||
assert info["onnx_asr"]
|
||||
assert info["available_providers"] == "CoreMLExecutionProvider, CPUExecutionProvider"
|
||||
assert info["asr_providers"] == "CPUExecutionProvider"
|
||||
assert info["vad_providers"] == "CPUExecutionProvider"
|
||||
assert info["quantization"] == "int8"
|
||||
+471
-294
@@ -4,11 +4,12 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from local_transcriber.transcriber import (
|
||||
ExecutionRequest,
|
||||
Segment,
|
||||
Transcriber,
|
||||
TranscribeResult,
|
||||
_transcribe_file,
|
||||
cuda_error_hint,
|
||||
ensure_model_available,
|
||||
load_model,
|
||||
transcribe,
|
||||
)
|
||||
from local_transcriber.types import WordTimestampsUnavailableError
|
||||
@@ -16,6 +17,21 @@ from local_transcriber.types import WordTimestampsUnavailableError
|
||||
# === Helpers ===
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[RuntimeError("out of memory"), RuntimeError("CUDA unknown error")],
|
||||
)
|
||||
def test_unknown_error_does_not_suggest_cuda_installation(error):
|
||||
"""CPU OOM и неизвестная причина не получают совет по установке CUDA."""
|
||||
assert cuda_error_hint(error) is None
|
||||
|
||||
|
||||
def test_windows_loader_error_suggests_extra():
|
||||
"""OSError загрузчика DLL распознаётся без обёртки RuntimeError."""
|
||||
error = OSError("[WinError 126] Could not find module 'cublas64_12.dll'")
|
||||
assert "uv sync --extra cuda" in cuda_error_hint(error)
|
||||
|
||||
|
||||
def _make_result(
|
||||
count: int = 2,
|
||||
language: str = "ru",
|
||||
@@ -216,7 +232,7 @@ def test_transcribe_reports_status_transitions(mock_get_backend):
|
||||
on_status=statuses.append,
|
||||
)
|
||||
|
||||
# load_model reports init status, _transcribe_file reports transcribe status
|
||||
# Статусы подготовки и распознавания идут через один callback
|
||||
assert any("Инициализирую модель" in s for s in statuses)
|
||||
assert any("Транскрибирую" in s for s in statuses)
|
||||
|
||||
@@ -279,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) ===
|
||||
|
||||
|
||||
@@ -476,202 +397,6 @@ def test_ensure_model_available_rejects_incomplete_local_directory(tmp_path):
|
||||
ensure_model_available(str(model_dir))
|
||||
|
||||
|
||||
# === Cross-backend fallback (openvino → cpu) ===
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_load_model_openvino_gpu_fallback_to_cpu(mock_get_backend):
|
||||
"""OpenVINO GPU ошибка при init → fallback на CPU (FasterWhisper)."""
|
||||
ov_backend = _make_backend(
|
||||
create_model_error=RuntimeError("OpenVINO model load failed"),
|
||||
)
|
||||
cpu_model = MagicMock()
|
||||
cpu_backend = _make_backend(model=cpu_model, model_path="/mock/cpu/model")
|
||||
|
||||
def backend_for_device(device, **kwargs):
|
||||
return ov_backend if device == "openvino-gpu" else cpu_backend
|
||||
|
||||
mock_get_backend.side_effect = backend_for_device
|
||||
|
||||
with pytest.warns(UserWarning, match="Переключение на CPU"):
|
||||
model, actual_device, backend, model_path = load_model(
|
||||
"medium",
|
||||
"openvino-gpu",
|
||||
"fp16",
|
||||
)
|
||||
|
||||
assert actual_device == "cpu"
|
||||
assert model is cpu_model
|
||||
assert backend is cpu_backend
|
||||
assert model_path == "/mock/cpu/model"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_load_model_openvino_cpu_fallback_to_cpu(mock_get_backend):
|
||||
"""OpenVINO CPU ошибка при init → fallback на CPU (FasterWhisper)."""
|
||||
ov_backend = _make_backend(
|
||||
create_model_error=RuntimeError("OpenVINO model load failed"),
|
||||
)
|
||||
cpu_model = MagicMock()
|
||||
cpu_backend = _make_backend(model=cpu_model, model_path="/mock/cpu/model")
|
||||
|
||||
def backend_for_device(device, **kwargs):
|
||||
return ov_backend if device == "openvino-cpu" else cpu_backend
|
||||
|
||||
mock_get_backend.side_effect = backend_for_device
|
||||
|
||||
with pytest.warns(UserWarning, match="Переключение на CPU"):
|
||||
model, actual_device, backend, model_path = load_model(
|
||||
"medium",
|
||||
"openvino-cpu",
|
||||
"int8",
|
||||
)
|
||||
|
||||
assert actual_device == "cpu"
|
||||
assert model is cpu_model
|
||||
assert backend is cpu_backend
|
||||
assert model_path == "/mock/cpu/model"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_transcribe_file_openvino_gpu_midstream_fallback(mock_get_backend):
|
||||
"""OpenVINO GPU ошибка при транскрипции → fallback на CPU."""
|
||||
ov_backend = _make_backend(
|
||||
transcribe_error=RuntimeError("OpenVINO inference error"),
|
||||
)
|
||||
cpu_backend = _make_backend(
|
||||
transcribe_result=_make_result(count=2, device_used="cpu"),
|
||||
model_path="/mock/cpu/model",
|
||||
)
|
||||
|
||||
def backend_for_device(device, **kwargs):
|
||||
return ov_backend if device.startswith("openvino") else cpu_backend
|
||||
|
||||
mock_get_backend.side_effect = backend_for_device
|
||||
|
||||
with pytest.warns(UserWarning, match="Переключение на CPU"):
|
||||
tfr = _transcribe_file(
|
||||
model=MagicMock(),
|
||||
actual_device="openvino-gpu",
|
||||
backend=ov_backend,
|
||||
model_path="/mock/ov/model",
|
||||
file_path=Path("test.mp3"),
|
||||
model_name="medium",
|
||||
compute_type="fp16",
|
||||
)
|
||||
|
||||
assert tfr.actual_device == "cpu"
|
||||
assert tfr.backend is cpu_backend
|
||||
assert tfr.model_path == "/mock/cpu/model"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_transcribe_file_does_not_fallback_for_missing_word_timestamps(
|
||||
mock_get_backend,
|
||||
):
|
||||
ov_backend = _make_backend(
|
||||
transcribe_error=WordTimestampsUnavailableError("нет таймкодов"),
|
||||
)
|
||||
|
||||
with pytest.raises(WordTimestampsUnavailableError, match="нет таймкодов"):
|
||||
_transcribe_file(
|
||||
model=MagicMock(),
|
||||
actual_device="openvino-gpu",
|
||||
backend=ov_backend,
|
||||
model_path="/mock/ov/model",
|
||||
file_path=Path("test.mp3"),
|
||||
model_name="medium",
|
||||
compute_type="fp16",
|
||||
)
|
||||
|
||||
mock_get_backend.assert_not_called()
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_transcribe_file_midstream_fallback_preserves_cpu_threads(mock_get_backend):
|
||||
ov_backend = _make_backend(
|
||||
transcribe_error=RuntimeError("OpenVINO inference error"),
|
||||
)
|
||||
cpu_backend = _make_backend(
|
||||
transcribe_result=_make_result(count=2, device_used="cpu"),
|
||||
model_path="/mock/cpu/model",
|
||||
)
|
||||
|
||||
def backend_for_device(device, **kwargs):
|
||||
return ov_backend if device.startswith("openvino") else cpu_backend
|
||||
|
||||
mock_get_backend.side_effect = backend_for_device
|
||||
|
||||
with pytest.warns(UserWarning, match="Переключение на CPU"):
|
||||
_transcribe_file(
|
||||
model=MagicMock(),
|
||||
actual_device="openvino-gpu",
|
||||
backend=ov_backend,
|
||||
model_path="/mock/ov/model",
|
||||
file_path=Path("test.mp3"),
|
||||
model_name="medium",
|
||||
compute_type="fp16",
|
||||
cpu_threads=6,
|
||||
)
|
||||
|
||||
assert cpu_backend.create_model.call_args.kwargs["cpu_threads"] == 6
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_openvino_gpu_strict_device_no_fallback(mock_get_backend):
|
||||
"""strict_device=True + OpenVINO GPU ошибка → raise."""
|
||||
backend = _make_backend(
|
||||
create_model_error=RuntimeError("OpenVINO model load failed"),
|
||||
)
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
with pytest.raises(RuntimeError, match="OpenVINO"):
|
||||
load_model("medium", "openvino-gpu", "fp16", strict_device=True)
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_openvino_runtime_error_triggers_fallback(mock_get_backend):
|
||||
"""Любой RuntimeError от OpenVINO бэкенда → fallback."""
|
||||
ov_backend = _make_backend(
|
||||
create_model_error=RuntimeError("Exception from src/inference/..."),
|
||||
)
|
||||
cpu_backend = _make_backend(model_path="/mock/cpu/model")
|
||||
|
||||
def backend_for_device(device, **kwargs):
|
||||
return ov_backend if device.startswith("openvino") else cpu_backend
|
||||
|
||||
mock_get_backend.side_effect = backend_for_device
|
||||
|
||||
with pytest.warns(UserWarning, match="Переключение на CPU"):
|
||||
_, actual_device, _, _ = load_model("medium", "openvino-cpu", "int8")
|
||||
|
||||
assert actual_device == "cpu"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_load_model_resolves_openvino_gpu_device(mock_get_backend):
|
||||
"""load_model обновляет actual_device по backend.actual_ov_device."""
|
||||
backend = _make_backend()
|
||||
backend.actual_ov_device = "GPU"
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
_, actual_device, _, _ = load_model("medium", "openvino-cpu", "int8")
|
||||
|
||||
assert actual_device == "openvino-gpu"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_load_model_resolves_openvino_cpu_device(mock_get_backend):
|
||||
"""load_model обновляет actual_device по backend.actual_ov_device."""
|
||||
backend = _make_backend()
|
||||
backend.actual_ov_device = "CPU"
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
_, actual_device, _, _ = load_model("medium", "openvino-gpu", "fp16")
|
||||
|
||||
assert actual_device == "openvino-cpu"
|
||||
|
||||
|
||||
def test_ensure_model_available_openvino_default_compute_type():
|
||||
"""ensure_model_available(device='openvino-cpu') без compute_type не падает."""
|
||||
from local_transcriber.backends.openvino import OpenVINOBackend
|
||||
@@ -681,3 +406,455 @@ def test_ensure_model_available_openvino_default_compute_type():
|
||||
repo, ct = backend._resolve_repo("medium", "int8")
|
||||
assert repo == "OpenVINO/whisper-medium-int8-ov"
|
||||
assert ct == "int8"
|
||||
|
||||
|
||||
# === Transcriber: module выполнения ===
|
||||
|
||||
|
||||
def _make_run_backend(**kwargs):
|
||||
"""Fake adapter для external interface: runtime_info возвращает словарь."""
|
||||
backend = _make_backend(**kwargs)
|
||||
backend.runtime_info.return_value = {}
|
||||
backend.engine = "fake-engine"
|
||||
backend.word_timestamps_available = True
|
||||
backend.actual_compute_type = None
|
||||
backend.actual_ov_device = None
|
||||
return backend
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_loads_model_once_for_two_files(mock_get_backend):
|
||||
"""Два файла переиспользуют одну загрузку модели."""
|
||||
backend = _make_run_backend()
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
run = Transcriber(ExecutionRequest(device="cpu", model="tiny", compute_type="int8"))
|
||||
first = run.transcribe(Path("a.mp3"))
|
||||
second = run.transcribe(Path("b.mp3"))
|
||||
|
||||
assert backend.create_model.call_count == 1
|
||||
assert backend.transcribe.call_count == 2
|
||||
assert first.device_used == "cpu"
|
||||
assert second.device_used == "cpu"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_does_not_load_model_before_first_file(mock_get_backend):
|
||||
"""Создание module без файлов не загружает модель."""
|
||||
mock_get_backend.return_value = _make_run_backend()
|
||||
|
||||
Transcriber(ExecutionRequest(device="cpu", model="tiny", compute_type="int8"))
|
||||
|
||||
mock_get_backend.assert_not_called()
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_auto_resolves_to_onnx_cpu_with_device_defaults(mock_get_backend):
|
||||
"""auto → ONNX CPU и его модель/квантизация по умолчанию; сведения различают запрошенное и выбранное."""
|
||||
backend = _make_run_backend()
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
info = Transcriber(ExecutionRequest()).prepare()
|
||||
|
||||
mock_get_backend.assert_called_once_with("onnx", compute_type_explicit=False)
|
||||
backend.ensure_model_available.assert_called_once_with(
|
||||
"gigaam-v3-e2e-rnnt", "int8", None
|
||||
)
|
||||
assert info.requested_device == "auto"
|
||||
assert info.device == "onnx"
|
||||
assert info.model == "gigaam-v3-e2e-rnnt"
|
||||
assert info.compute_type == "int8"
|
||||
assert info.description == "ONNX (CPU)"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_falls_back_to_cpu_at_load_and_keeps_state_for_next_file(
|
||||
mock_get_backend,
|
||||
):
|
||||
"""Не-strict CUDA-ошибка при загрузке → CPU; следующий файл идёт на CPU без перезагрузки."""
|
||||
cuda_backend = _make_run_backend(
|
||||
create_model_error=RuntimeError("CUDA out of memory")
|
||||
)
|
||||
cpu_backend = _make_run_backend()
|
||||
mock_get_backend.side_effect = lambda device, **_: (
|
||||
cuda_backend if device == "cuda" else cpu_backend
|
||||
)
|
||||
|
||||
run = Transcriber(
|
||||
ExecutionRequest(
|
||||
device="cuda",
|
||||
model="tiny",
|
||||
compute_type="int8",
|
||||
strict_device=False,
|
||||
cpu_threads=3,
|
||||
)
|
||||
)
|
||||
with pytest.warns(UserWarning, match="Переключение на CPU"):
|
||||
first = run.transcribe(Path("a.mp3"))
|
||||
second = run.transcribe(Path("b.mp3"))
|
||||
|
||||
assert first.device_used == "cpu"
|
||||
assert second.device_used == "cpu"
|
||||
assert cpu_backend.create_model.call_count == 1
|
||||
assert cpu_backend.create_model.call_args.kwargs["cpu_threads"] == 3
|
||||
assert run.execution.resolved_device == "cuda"
|
||||
assert run.execution.device == "cpu"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_strict_device_raises_without_fallback(mock_get_backend):
|
||||
"""Явный device (strict по умолчанию) не подменяется другим исполнением."""
|
||||
mock_get_backend.return_value = _make_run_backend(
|
||||
create_model_error=RuntimeError("CUDA driver version is insufficient")
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="driver version"):
|
||||
Transcriber(
|
||||
ExecutionRequest(device="cuda", model="tiny", compute_type="int8")
|
||||
).prepare()
|
||||
|
||||
assert mock_get_backend.call_count == 1
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_midstream_fallback_reloads_once_and_continues_batch(mock_get_backend):
|
||||
"""CUDA-ошибка во время распознавания → повтор файла на CPU; батч продолжается на CPU."""
|
||||
cuda_backend = _make_run_backend(
|
||||
transcribe_error=RuntimeError("CUBLAS_STATUS_ALLOC_FAILED")
|
||||
)
|
||||
cpu_backend = _make_run_backend()
|
||||
mock_get_backend.side_effect = lambda device, **_: (
|
||||
cuda_backend if device == "cuda" else cpu_backend
|
||||
)
|
||||
|
||||
run = Transcriber(
|
||||
ExecutionRequest(
|
||||
device="cuda", model="tiny", compute_type="int8", strict_device=False
|
||||
)
|
||||
)
|
||||
with pytest.warns(UserWarning, match="Переключение на CPU и повтор"):
|
||||
first = run.transcribe(Path("a.mp3"))
|
||||
second = run.transcribe(Path("b.mp3"))
|
||||
|
||||
assert first.device_used == "cpu"
|
||||
assert second.device_used == "cpu"
|
||||
assert cpu_backend.create_model.call_count == 1
|
||||
assert cpu_backend.transcribe.call_count == 2
|
||||
assert run.execution.device == "cpu"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_ordinary_file_error_does_not_change_state(mock_get_backend):
|
||||
"""Ошибка файла (не движка) пробрасывается, модель и исполнение остаются прежними."""
|
||||
backend = _make_run_backend()
|
||||
backend.transcribe.side_effect = [
|
||||
ValueError("Не удалось декодировать"),
|
||||
_make_result(),
|
||||
]
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
run = Transcriber(
|
||||
ExecutionRequest(
|
||||
device="cuda", model="tiny", compute_type="int8", strict_device=False
|
||||
)
|
||||
)
|
||||
with pytest.raises(ValueError, match="декодировать"):
|
||||
run.transcribe(Path("bad.mp3"))
|
||||
result = run.transcribe(Path("good.mp3"))
|
||||
|
||||
assert result.device_used == "cuda"
|
||||
assert backend.create_model.call_count == 1
|
||||
assert mock_get_backend.call_count == 1
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_word_timestamps_error_is_not_a_fallback_reason(mock_get_backend):
|
||||
"""Нарушение пословного контракта не считается ошибкой движка."""
|
||||
mock_get_backend.return_value = _make_run_backend(
|
||||
transcribe_error=WordTimestampsUnavailableError("нет слов")
|
||||
)
|
||||
|
||||
run = Transcriber(
|
||||
ExecutionRequest(
|
||||
device="cuda", model="tiny", compute_type="int8", strict_device=False
|
||||
)
|
||||
)
|
||||
with pytest.raises(WordTimestampsUnavailableError):
|
||||
run.transcribe(Path("a.mp3"))
|
||||
|
||||
assert mock_get_backend.call_count == 1
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_requires_word_timestamps_before_first_file(mock_get_backend):
|
||||
"""Требование пословного контракта проверяется при подготовке, ASR не запускается."""
|
||||
backend = _make_run_backend()
|
||||
backend.word_timestamps_available = False
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
run = Transcriber(
|
||||
ExecutionRequest(
|
||||
device="onnx", model="some/raw-model", require_word_timestamps=True
|
||||
)
|
||||
)
|
||||
with pytest.raises(ValueError, match="пословные таймкоды"):
|
||||
run.transcribe(Path("a.mp3"))
|
||||
|
||||
backend.transcribe.assert_not_called()
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_reports_actual_openvino_device(mock_get_backend):
|
||||
"""openvino-gpu, выбранный до загрузки, уточняется по фактическому устройству OpenVINO."""
|
||||
backend = _make_run_backend()
|
||||
backend.actual_ov_device = "CPU"
|
||||
backend.actual_compute_type = "int8"
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
with patch(
|
||||
"local_transcriber.transcriber.detect_device", return_value="openvino-gpu"
|
||||
):
|
||||
info = Transcriber(
|
||||
ExecutionRequest(device="openvino", model="medium")
|
||||
).prepare()
|
||||
|
||||
assert info.requested_device == "openvino"
|
||||
assert info.resolved_device == "openvino-gpu"
|
||||
assert info.device == "openvino-cpu"
|
||||
assert info.compute_type == "int8"
|
||||
assert info.description == "OpenVINO (CPU)"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_gpu_name", return_value="RTX 3060")
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_exposes_runtime_and_gpu_name_from_module_data(mock_get_backend, _gpu):
|
||||
"""Сведения о runtime берутся из adapter'а, имя GPU — из драйвера, потоки — из запроса."""
|
||||
backend = _make_run_backend()
|
||||
backend.runtime_info.return_value = {"ctranslate2": "4.5.0"}
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
info = Transcriber(
|
||||
ExecutionRequest(
|
||||
device="cuda", model="tiny", compute_type="float16", cpu_threads=4
|
||||
)
|
||||
).prepare()
|
||||
|
||||
assert info.description == "CUDA (RTX 3060)"
|
||||
assert info.runtime == {"ctranslate2": "4.5.0"}
|
||||
assert info.cpu_threads == 4
|
||||
backend.create_model.assert_called_once_with(
|
||||
"/mock/model", "cuda", "float16", cpu_threads=4
|
||||
)
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_passes_auto_language_as_none(mock_get_backend):
|
||||
backend = _make_run_backend()
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
Transcriber(
|
||||
ExecutionRequest(device="cpu", model="tiny", language="auto")
|
||||
).transcribe(Path("a.mp3"))
|
||||
|
||||
assert backend.transcribe.call_args[0][2] is None
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_public_transcribe_uses_the_same_execution_module(mock_get_backend):
|
||||
"""transcribe() без параметров идёт тем же путём, что CLI по умолчанию: ONNX CPU."""
|
||||
backend = _make_run_backend(transcribe_result=_make_result(count=1))
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
result = transcribe(Path("a.mp3"))
|
||||
|
||||
mock_get_backend.assert_called_once_with("onnx", compute_type_explicit=False)
|
||||
backend.ensure_model_available.assert_called_once_with(
|
||||
"gigaam-v3-e2e-rnnt", "int8", None
|
||||
)
|
||||
assert result.device_used == "onnx"
|
||||
assert len(result.segments) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("device", "ov_device", "gpu_name", "intel_name", "expected"),
|
||||
[
|
||||
("cpu", None, None, None, "CPU"),
|
||||
("onnx", None, "RTX 3060", None, "ONNX (CPU)"),
|
||||
("cuda", None, "RTX 3060", None, "CUDA (RTX 3060)"),
|
||||
("cuda", None, None, None, "CUDA (Unknown GPU)"),
|
||||
("openvino-gpu", "GPU", None, "Intel Arc 140T", "OpenVINO (Intel Arc 140T)"),
|
||||
("openvino-gpu", "GPU", None, None, "OpenVINO (Intel GPU)"),
|
||||
("openvino-cpu", "CPU", None, None, "OpenVINO (CPU)"),
|
||||
],
|
||||
)
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_description_names_hardware_only_from_driver_data(
|
||||
mock_get_backend, device, ov_device, gpu_name, intel_name, expected
|
||||
):
|
||||
"""Строка исполнения для шапки: ONNX не выдаётся за CPU faster-whisper, GPU — по драйверу."""
|
||||
backend = _make_run_backend()
|
||||
backend.actual_ov_device = ov_device
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
with (
|
||||
patch("local_transcriber.transcriber.get_gpu_name", return_value=gpu_name),
|
||||
patch("local_transcriber.transcriber.get_intel_gpu_name", return_value=intel_name),
|
||||
):
|
||||
info = Transcriber(ExecutionRequest(device=device, model="m")).prepare()
|
||||
|
||||
assert info.description == expected
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_openvino_runtime_error_falls_back_when_not_strict(mock_get_backend):
|
||||
"""Любой RuntimeError OpenVINO считается ошибкой движка: не-strict запуск уходит на CPU."""
|
||||
ov_backend = _make_run_backend(
|
||||
create_model_error=RuntimeError("Exception from src/inference/src/core.cpp")
|
||||
)
|
||||
cpu_backend = _make_run_backend()
|
||||
mock_get_backend.side_effect = lambda device, **_: (
|
||||
ov_backend if device.startswith("openvino") else cpu_backend
|
||||
)
|
||||
|
||||
run = Transcriber(
|
||||
ExecutionRequest(device="openvino-gpu", model="medium", strict_device=False)
|
||||
)
|
||||
with pytest.warns(UserWarning, match="Переключение на CPU"):
|
||||
result = run.transcribe(Path("a.mp3"))
|
||||
|
||||
assert result.device_used == "cpu"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_openvino_strict_raises_without_fallback(mock_get_backend):
|
||||
mock_get_backend.return_value = _make_run_backend(
|
||||
create_model_error=RuntimeError("GPU plugin failed")
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="GPU plugin"):
|
||||
Transcriber(ExecutionRequest(device="openvino-gpu", model="medium")).prepare()
|
||||
|
||||
assert mock_get_backend.call_count == 1
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_reports_openvino_gpu_chosen_for_cpu_request(mock_get_backend):
|
||||
"""Фактическое устройство OpenVINO важнее запрошенного и в обратную сторону."""
|
||||
backend = _make_run_backend()
|
||||
backend.actual_ov_device = "GPU"
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
info = Transcriber(ExecutionRequest(device="openvino-cpu", model="medium")).prepare()
|
||||
|
||||
assert info.resolved_device == "openvino-cpu"
|
||||
assert info.device == "openvino-gpu"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_fallback_rederives_implicit_compute_type_for_cpu(mock_get_backend):
|
||||
"""Неявный compute_type следует за фактическим устройством: CPU не получает float16."""
|
||||
cuda_backend = _make_run_backend(create_model_error=RuntimeError("CUDA error"))
|
||||
cpu_backend = _make_run_backend()
|
||||
mock_get_backend.side_effect = lambda device, **_: (
|
||||
cuda_backend if device == "cuda" else cpu_backend
|
||||
)
|
||||
|
||||
run = Transcriber(ExecutionRequest(device="cuda", strict_device=False))
|
||||
with pytest.warns(UserWarning, match="Переключение на CPU"):
|
||||
info = run.prepare()
|
||||
|
||||
cuda_backend.ensure_model_available.assert_called_once_with("medium", "float16", None)
|
||||
cpu_backend.ensure_model_available.assert_called_once_with("medium", "float32", None)
|
||||
assert info.compute_type == "float32"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_fallback_keeps_explicit_compute_type(mock_get_backend):
|
||||
cuda_backend = _make_run_backend(create_model_error=RuntimeError("CUDA error"))
|
||||
cpu_backend = _make_run_backend()
|
||||
mock_get_backend.side_effect = lambda device, **_: (
|
||||
cuda_backend if device == "cuda" else cpu_backend
|
||||
)
|
||||
|
||||
run = Transcriber(
|
||||
ExecutionRequest(device="cuda", compute_type="int8", strict_device=False)
|
||||
)
|
||||
with pytest.warns(UserWarning):
|
||||
run.prepare()
|
||||
|
||||
cpu_backend.ensure_model_available.assert_called_once_with("medium", "int8", None)
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_failed_capability_check_does_not_mark_module_prepared(mock_get_backend):
|
||||
"""После отказа по пословному контракту повторный вызов не запускает ASR."""
|
||||
backend = _make_run_backend()
|
||||
backend.word_timestamps_available = False
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
run = Transcriber(
|
||||
ExecutionRequest(device="onnx", model="some/raw-model", require_word_timestamps=True)
|
||||
)
|
||||
with pytest.raises(ValueError, match="пословные таймкоды"):
|
||||
run.prepare()
|
||||
with pytest.raises(ValueError, match="пословные таймкоды"):
|
||||
run.transcribe(Path("a.mp3"))
|
||||
|
||||
backend.transcribe.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("device", "model", "compute_type"),
|
||||
[
|
||||
("cpu", "medium", "float32"),
|
||||
("cuda", "medium", "float16"),
|
||||
("openvino-cpu", "medium", "int8"),
|
||||
("openvino-gpu", "medium", "int8"),
|
||||
("onnx", "gigaam-v3-e2e-rnnt", "int8"),
|
||||
],
|
||||
)
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_applies_device_defaults_when_request_leaves_them_empty(
|
||||
mock_get_backend, device, model, compute_type
|
||||
):
|
||||
backend = _make_run_backend()
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
Transcriber(ExecutionRequest(device=device)).prepare()
|
||||
|
||||
mock_get_backend.assert_called_once_with(device, compute_type_explicit=False)
|
||||
backend.ensure_model_available.assert_called_once_with(model, compute_type, None)
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_explicit_values_override_device_defaults(mock_get_backend):
|
||||
backend = _make_run_backend()
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
Transcriber(ExecutionRequest(device="cuda", model="large-v3", compute_type="int8")).prepare()
|
||||
|
||||
mock_get_backend.assert_called_once_with("cuda", compute_type_explicit=True)
|
||||
backend.ensure_model_available.assert_called_once_with("large-v3", "int8", None)
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_engine_comes_from_adapter(mock_get_backend):
|
||||
backend = _make_run_backend()
|
||||
backend.engine = "openvino"
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
info = Transcriber(ExecutionRequest(device="openvino-cpu", model="medium")).prepare()
|
||||
|
||||
assert info.engine == "openvino"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_diagnostics_failure_does_not_fail_the_run(mock_get_backend):
|
||||
"""Сбой сбора runtime_info не роняет распознавание, а попадает в сведения."""
|
||||
backend = _make_run_backend()
|
||||
backend.runtime_info.side_effect = RuntimeError("plugin enumeration failed")
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
info = Transcriber(ExecutionRequest(device="cpu", model="tiny")).prepare()
|
||||
|
||||
assert "plugin enumeration failed" in info.runtime["diagnostics"]
|
||||
+5
-5
@@ -66,17 +66,17 @@ def test_detect_device_explicit_passthrough():
|
||||
assert detect_device("openvino-cpu") == "openvino-cpu"
|
||||
|
||||
|
||||
def test_detect_device_auto_cuda_when_nvidia_smi_available():
|
||||
"""При доступном nvidia-smi auto выбирает CUDA."""
|
||||
def test_detect_device_auto_onnx_when_nvidia_smi_available():
|
||||
"""При доступном nvidia-smi auto остаётся на ONNX CPU."""
|
||||
with patch(
|
||||
"local_transcriber.utils.shutil.which", return_value="/usr/bin/nvidia-smi"
|
||||
"shutil.which", return_value="/usr/bin/nvidia-smi"
|
||||
):
|
||||
assert detect_device("auto") == "cuda"
|
||||
assert detect_device("auto") == "onnx"
|
||||
|
||||
|
||||
def test_detect_device_auto_onnx_without_cuda():
|
||||
"""Без CUDA auto выбирает ONNX CPU."""
|
||||
with patch("local_transcriber.utils.shutil.which", return_value=None):
|
||||
with patch("shutil.which", return_value=None):
|
||||
assert detect_device("auto") == "onnx"
|
||||
|
||||
|
||||
|
||||
@@ -288,9 +288,9 @@ name = "local-transcriber"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "ctranslate2" },
|
||||
{ name = "faster-whisper" },
|
||||
{ name = "httpx" },
|
||||
{ name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
{ name = "onnx-asr", extra = ["cpu", "hub"] },
|
||||
{ name = "onnxruntime" },
|
||||
{ name = "openvino-genai", marker = "(platform_machine == 'AMD64' and sys_platform != 'darwin') or (platform_machine == 'x86_64' and sys_platform != 'darwin')" },
|
||||
@@ -300,6 +300,11 @@ dependencies = [
|
||||
{ name = "typer" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
cuda = [
|
||||
{ name = "nvidia-cublas-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'x86_64' and sys_platform == 'win32')" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "kaldi-native-fbank" },
|
||||
@@ -311,9 +316,10 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "ctranslate2", specifier = "==4.8.1" },
|
||||
{ name = "faster-whisper", specifier = ">=1.2.1,<2" },
|
||||
{ name = "httpx", specifier = ">=0.28,<1" },
|
||||
{ name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=12.4,<13" },
|
||||
{ name = "nvidia-cublas-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'cuda') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'cuda') or (platform_machine == 'x86_64' and sys_platform == 'win32' and extra == 'cuda')", specifier = "==12.9.1.4" },
|
||||
{ name = "onnx-asr", extras = ["cpu", "hub"], specifier = ">=0.12,<0.13" },
|
||||
{ name = "onnxruntime", specifier = ">=1.28,<2" },
|
||||
{ name = "openvino-genai", marker = "(platform_machine == 'AMD64' and sys_platform != 'darwin') or (platform_machine == 'x86_64' and sys_platform != 'darwin')", specifier = ">=2026.3.0.0,<2026.4" },
|
||||
@@ -322,6 +328,7 @@ requires-dist = [
|
||||
{ name = "socksio", specifier = ">=1.0.0,<2" },
|
||||
{ name = "typer", specifier = ">=0.24.1,<1" },
|
||||
]
|
||||
provides-extras = ["cuda"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
@@ -445,6 +452,7 @@ version = "12.9.1.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/77/3c/aa88abe01f3be3d1f8f787d1d33dc83e76fec05945f9a28fbb41cfb99cd5/nvidia_cublas_cu12-12.9.1.4-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:453611eb21a7c1f2c2156ed9f3a45b691deda0440ec550860290dc901af5b4c2", size = 581242350, upload-time = "2025-06-05T20:04:51.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/a1/a17fade6567c57452cfc8f967a40d1035bb9301db52f27808167fbb2be2f/nvidia_cublas_cu12-12.9.1.4-py3-none-win_amd64.whl", hash = "sha256:1e5fee10662e6e52bd71dec533fbbd4971bb70a5f24f3bc3793e5c2e9dc640bf", size = 553153899, upload-time = "2025-06-05T20:13:35.556Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in new issue
Block a user