Compare commits

...
19 Commits
Author SHA1 Message Date
Dmitry DementievandClaude Opus 5 9b40e2b4c1 docs(readme): команды установки снова ссылаются на GitHub
Зеркало на GitHub доступно и практичнее для пользователей, чем приватный Gitea.
Ссылки на задачи в ADR и бенчмарках остаются на Gitea — это трекер.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 10:19:07 +03:00
Dmitry DementievandClaude Opus 5 b315f60746 docs(agents): убрано упоминание GitHub-зеркала как remote
Зеркало поддерживается средствами Gitea и в клоне как remote не существует;
строка вводила агентов в заблуждение.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 10:13:13 +03:00
Dmitry DementievandClaude Opus 5 50787c6531 docs(research): архивировано исследование ускорения на AMD из ветки feature/amd-acceleration
Два документа от 23–24 марта 2026 (DirectML, whisper.cpp + Vulkan на Radeon 780M,
XDNA NPU и бенчмарк качества на Ryzen) перенесены в docs/research и docs/benchmarks
с пометкой «архив». Выводы не реализованы: ADR-003 выбрал OpenVINO GenAI, авто-выбор
устройства закреплён за ONNX CPU. Ветка после переноса удаляется.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 10:06:13 +03:00
ddmitry 328580abfb feat(transcriber): deep module выполнения транскрипции (#58)
Закрывает #56. Спецификация: docs/specs/2026-09-21-transcription-execution.md.
2026-09-22 09:46:22 +03:00
Dmitry DementievandClaude Opus 5 63307fed01 docs(adr): ADR-003 описывает engine adapter'а вместо удалённой таблицы
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:36:26 +03:00
Dmitry DementievandClaude Opus 5 6f1064ccc4 fix(transcriber): закрыты находки ревью по module выполнения
- неявный compute_type при fallback переразрешается по умолчанию CPU
  (float16 CUDA не переносится на CTranslate2 CPU);
- prepare() помечает module готовым только после проверки пословного
  контракта;
- движок сообщает сам adapter (атрибут engine) вместо параллельной таблицы;
- сбой runtime_info() не роняет запуск;
- каскад конфига оставляет model/compute_type пустыми для module,
  CLI не дублирует CLI > TOML;
- удалены недостижимые из CLI сообщения о mid-stream fallback;
- в --verbose потоки помечены как запрошенные, adapters сообщают
  применённые; OpenVINO прямо говорит, что --threads не применяется;
- тесты: умолчания по устройствам, TOML compute_type, не-strict сценарий
  обычной ошибки; CONTRIBUTING.md и AGENTS.md без устаревших имён.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:32:04 +03:00
Dmitry DementievandClaude Opus 5 f7c0ed9a8c fix(transcriber): пустой язык трактуется как автоопределение, как раньше
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:06:03 +03:00
Dmitry DementievandClaude Opus 5 a8241b1d6b docs(execution): описаны владение выполнением и диагностика
ADR-002 и ADR-003 отражают Transcriber как единственного владельца
загрузки и fallback; README получил раздел «Диагностика исполнения»
и уточнение --threads для ONNX; AGENTS.md — новые тестовые паттерны.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:05:27 +03:00
Dmitry DementievandClaude Opus 5 0f8ecb5bd3 refactor(cli): одиночный файл и батч переведены на module выполнения
CLI строит один ExecutionRequest, создаёт Transcriber после prescan
и получает результат с ExecutionInfo: разрешение auto, device-aware
умолчания, проверка пословного контракта, строка устройства для шапки
и fallback ушли за seam. --verbose печатает движок, потоки и runtime_info().
Удалены load_model, _transcribe_file, TranscribeFileResult,
apply_device_defaults и тесты переноса полей; сценарные тесты CLI идут
через CliRunner с fake adapter на get_backend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:05:27 +03:00
Dmitry DementievandClaude Opus 5 e22b2f1bae feat(onnx): бюджет потоков применяется к сессиям ASR и VAD
--threads больше не игнорируется ONNX adapter'ом: cpu_threads > 0 задаёт
intra_op_num_threads через SessionOptions обеим сессиям, 0 оставляет
настройки onnxruntime. Фактический бюджет виден в runtime_info().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:55:28 +03:00
Dmitry DementievandClaude Opus 5 4dea7f4bc6 feat(transcriber): добавлен module выполнения с владением моделью и исполнением
Transcriber принимает ExecutionRequest, разрешает auto и device-aware
умолчания, загружает модель один раз на запуск и держит fallback внутри.
Caller получает TranscribeResult и ExecutionInfo вместо model/backend/path.
Публичный transcribe() идёт через тот же module. Adapters отдают
runtime_info() для диагностики; legacy load_model/_transcribe_file пока
остаются для CLI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:46:54 +03:00
ddmitry 343a31b35b feat(cuda): добавлен необязательный runtime для Linux и Windows (#57)
Closes #2. Независимое ревью Opus: APPROVED; 524 теста прошли, 2 пропущены. Реальный Windows GPU-прогон без Toolkit остаётся невыполненным и зафиксирован в документации.
2026-09-21 23:31:29 +03:00
Dmitry Dementiev a8b8a8c0c9 fix(cuda): учтён отказ по неподдерживаемому типу вычислений 2026-09-21 23:20:51 +03:00
Dmitry Dementiev 453b5d9dfa docs(cuda): описаны установка extra и проверка Windows DLL 2026-09-21 22:33:51 +03:00
Dmitry Dementiev 99ed5ed92f feat(cuda): добавлен optional runtime для Linux и Windows 2026-09-21 22:31:30 +03:00
Dmitry Dementiev 937eafe8db fix(device): закреплён ONNX CPU для автоматического выбора 2026-09-21 22:20:27 +03:00
Dmitry Dementiev 2b54f5bc9e docs(architecture): согласовано владение выполнением транскрипции 2026-09-21 21:55:04 +03:00
Dmitry DementievandClaude Opus 5 671ac8e5c9 docs(readme): уточнена установка из клона и пункт меню «Отправить»
- Зачем:
  - коллеги ставят инструмент из внутреннего репозитория с другим URL.
- Что:
  - добавлен вариант установки и обновления из склонированного репозитория.
  - шаг с --install-menu вынесен в список установки, шаги перенумерованы.
  - добавлена подсказка открыть новую консоль после установки uv.
- Проверка:
  - git diff --check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vByFj9s7qBHB7QRCf6mri
2026-09-14 11:24:10 +03:00
Dmitry DementievandClaude Opus 5 3860f52eb9 docs(claude): добавлены правила стиля и выбора подхода
- Зачем:
  - агенту нужны локальные правила изложения и экономии токенов в этом репозитории.
- Что:
  - добавлены разделы про стиль изложения и удержание выбранного подхода.
  - добавлены указания для Fable 5/5.1: точечные правки и пакетные запросы.
- Проверка:
  - git diff --check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vByFj9s7qBHB7QRCf6mri
2026-09-14 11:24:10 +03:00
34 changed files with 2544 additions and 1820 deletions

No files matched your search

+13 -10
View File
@@ -25,11 +25,12 @@ Package manager is **uv** (not pip). Build backend is hatchling.
``` ```
CLI (cli.py) CLI (cli.py)
→ config.py cascade: CLI arg → .transcriber.toml → device-aware default → hardcoded → config.py cascade: CLI arg → .transcriber.toml (model/compute_type stay None for the module to resolve)
→ utils.py detect_device(), validate files, expand globs (Windows workaround) → utils.py detect_device(), validate files, expand globs (Windows workaround)
→ context_menu.py Windows SendTo: Transcribe.cmd install/uninstall (--install-menu / --uninstall-menu) → context_menu.py Windows SendTo: Transcribe.cmd install/uninstall (--install-menu / --uninstall-menu)
→ transcriber.py load_model() → get_backend(device) → ensure_model_available → create_model → transcriber.py Transcriber(ExecutionRequest): resolves auto + device defaults,
_transcribe_file() with mid-stream CUDA→CPU fallback get_backend(device) → ensure_model_available → create_model once per run,
transcribe(file) per file; fallback and ExecutionInfo stay inside
→ formatter.py segments → markdown with timestamps, paragraph grouping (>2s pause or >60s) → formatter.py segments → markdown with timestamps, paragraph grouping (>2s pause or >60s)
``` ```
@@ -47,19 +48,21 @@ Three backends implement the `Backend` Protocol (structural typing, no inheritan
### Key design decisions ### Key design decisions
- **Two-level fallback**: GPU→CPU at model load time AND mid-stream during transcription (GPU visible via nvidia-smi but insufficient VRAM). - **Execution module** (`Transcriber`): one owner per run for model handle, adapter, actual device and allowed GPU→CPU fallback (load time and mid-stream). Callers get `TranscribeResult` + `ExecutionInfo` (requested vs actual device, engine, model, compute type, threads, `runtime_info()` from the adapter for `--verbose`). Fallback is reachable only with `strict_device=False` (Python API): CLI explicit device is strict, `auto` is ONNX CPU.
- **CUDA bootstrap** (`_cuda_bootstrap.py`): preloads `libcublas.so.12` via `ctypes.CDLL(RTLD_GLOBAL)` before importing ctranslate2, because pip's `nvidia-cublas-cu12` installs to a non-standard path and `LD_LIBRARY_PATH` can't be changed at runtime (glibc caches it). - **CUDA bootstrap** (`_cuda_bootstrap.py`): preloads `libcublas.so.12` via `ctypes.CDLL(RTLD_GLOBAL)` before importing ctranslate2, because pip's `nvidia-cublas-cu12` installs to a non-standard path and `LD_LIBRARY_PATH` can't be changed at runtime (glibc caches it).
- **Batch mode**: 3-phase pipeline (prescan → load model once → transcribe all). `TranscribeFileResult` carries updated model/backend/device state between files. - **Batch mode**: 3-phase pipeline (prescan → create `Transcriber` → transcribe all). No state is carried between files by the CLI.
- **Device-aware defaults**: `compute_type` and `model` vary by device (float16 for CUDA, int8 for OpenVINO, float32 for CPU). Defined in `config.py` `DEVICE_DEFAULTS`. - **Device-aware defaults**: `compute_type` and `model` vary by device (float16 for CUDA, int8 for OpenVINO, float32 for CPU). Defined in `config.py` `DEVICE_DEFAULTS`, applied by `Transcriber` when the request leaves them `None`.
- **ONNX thread budget**: `--threads` sets `intra_op_num_threads` for both ASR and VAD sessions via `SessionOptions`; 0 keeps onnxruntime defaults.
- **OpenVINO uses pre-quantized models** — `compute_type` selects which HF repo to download, not a runtime parameter. - **OpenVINO uses pre-quantized models** — `compute_type` selects which HF repo to download, not a runtime parameter.
## Testing ## Testing
All tests mock backends — no real model downloads or transcription. Key test patterns: All tests mock backends — no real model downloads or transcription. Key test patterns:
- CLI tests: `typer.testing.CliRunner` + mocks for `load_config`, `detect_device`, `load_model`, `_transcribe_file`, `write_transcript` - CLI tests: `typer.testing.CliRunner` with the real `Transcriber` and a fake adapter patched at `local_transcriber.transcriber.get_backend`; `load_config`, `validate_input_file`, `write_transcript` mocked
- `_single_patches()` — helper assembling standard happy-path mock set - `_cli_run()` — context manager assembling that standard set, yields `(backend, write_transcript)`
- `_make_result()` / `_make_tfr()` — factories for test data - `_make_result()` / `_make_backend()` — factories for test data
- Module tests (`test_transcriber.py`): `Transcriber` through its interface with `_make_run_backend()`; adapter tests patch the libraries (`onnx_asr.load_model`, `faster_whisper.WhisperModel`)
## Common tasks ## Common tasks
@@ -79,7 +82,7 @@ All tests mock backends — no real model downloads or transcription. Key test p
### Issue tracker ### Issue tracker
Задачи ведутся в Gitea через `tea`; GitHub используется только как зеркало, внешние PR не входят в triage. См. `docs/agents/issue-tracker.md`. Задачи ведутся в Gitea через `tea`; внешние PR не входят в triage. См. `docs/agents/issue-tracker.md`.
### Triage labels ### Triage labels
+20
View File
@@ -1 +1,21 @@
@AGENTS.md @AGENTS.md
# Стиль изложения
Пиши легко для чтения. Сокращай количество мыслей, а не количество слов, которыми выражена каждая мысль.
Please remove all mannered prose.
# Выбрать подход и держаться его
Выбирая, как решать задачу, возьми один подход и доведи его до конца. Не пересматривай
решение, пока не появится новая информация, которая прямо противоречит твоим
рассуждениям. Если колеблешься между двумя подходами, выбери один и посмотри, куда он
приведёт; свернуть можно позже, если выбранный не сработал.
# Для моделей Fable 5 и 5.1
При прочих равных экономь токены на правке файлов: когда результат не меняется, правь
файл точечно, а не переписывай целиком.
Сначала про себя составь список того, что нужно дальше; затем в одном ответе запроси
всё, что не зависит от результата другого запроса.
+8 -8
View File
@@ -43,25 +43,25 @@ tests/
CLI (cli.py) CLI (cli.py)
→ config.py: загрузка .transcriber.toml, каскад дефолтов → config.py: загрузка .transcriber.toml, каскад дефолтов
→ utils.py: валидация файлов, определение устройства → utils.py: валидация файлов, определение устройства
→ transcriber.py: загрузка модели (с CUDA fallback), транскрипция → transcriber.py: Transcriber — разрешение устройства, загрузка модели один раз на запуск, транскрипция, fallback
→ formatter.py: сегменты → markdown с таймкодами → formatter.py: сегменты → markdown с таймкодами
→ запись результата → запись результата
``` ```
## Ключевые архитектурные решения ## Ключевые архитектурные решения
- **CUDA fallback** — двухуровневый: при загрузке модели и при транскрипции (mid-stream). GPU может быть видна через nvidia-smi, но не иметь достаточно VRAM. - **Module выполнения** (`Transcriber` в `transcriber.py`) — один владелец модели, adapter'а и фактического исполнения на запуск. Fallback GPU→CPU (при загрузке и mid-stream) живёт внутри и доступен только при `strict_device=False` из Python API: явный device в CLI strict, `auto` — ONNX CPU.
- **Device-aware дефолты** — `compute_type` зависит от устройства (`float16`/`float32`). `float16` не работает на CPU, `float32` расточителен на GPU. - **Device-aware дефолты** — `model` и `compute_type` без явного значения разрешаются по устройству при загрузке (`float16`/`float32`). `float16` не работает на CPU, `float32` расточителен на GPU.
- **cuBLAS bootstrap** (`_cuda_bootstrap.py`) — preload через ctypes до импорта ctranslate2. pip-пакет `nvidia-cublas-cu12` ставит `.so` в нестандартное место, а `LD_LIBRARY_PATH` нельзя изменить в рантайме. - **cuBLAS bootstrap** (`_cuda_bootstrap.py`) — preload через ctypes до импорта ctranslate2. pip-пакет `nvidia-cublas-cu12` ставит `.so` в нестандартное место, а `LD_LIBRARY_PATH` нельзя изменить в рантайме.
- **Батч-режим** — 3 фазы (prescan → load model → transcribe). Модель загружается один раз (~2-5 сек), невалидные файлы отсеиваются до загрузки. - **Батч-режим** — 3 фазы (prescan → создание `Transcriber` → transcribe). Модель загружается один раз (~2-5 сек), невалидные файлы отсеиваются до загрузки; CLI не переносит состояние между файлами.
- **Ручной glob в utils** — typer на Windows не раскрывает `*.mp4`, поэтому глобы обрабатываются явно. - **Ручной glob в utils** — typer на Windows не раскрывает `*.mp4`, поэтому глобы обрабатываются явно.
## Тестирование ## Тестирование
- Все CLI-тесты через `typer.testing.CliRunner` + моки (faster-whisper не вызывается) - Все CLI-тесты через `typer.testing.CliRunner` с настоящим `Transcriber` и fake adapter, подменённым в `local_transcriber.transcriber.get_backend` (библиотеки не вызываются)
- Моки: `load_config`, `validate_input_file`, `detect_device`, `ensure_model_available`, `load_model`, `_transcribe_file`, `write_transcript` - Моки: `load_config`, `validate_input_file`, `write_transcript`
- Паттерн: `_single_patches()` — хелпер для стандартного happy-path набора моков - Паттерн: `_cli_run()` — контекстный менеджер стандартного набора, отдаёт `(backend, write_transcript)`
- `_make_result()` / `_make_tfr()` — фабрики тестовых данных - `_make_result()` / `_make_backend()` — фабрики тестовых данных
## Частые задачи ## Частые задачи
+130 -37
View File
@@ -8,7 +8,7 @@ transcribe meeting.mp4
``` ```
- **Полностью локально** — данные не покидают машину - **Полностью локально** — данные не покидают машину
- **Авто-ускорение** — NVIDIA CUDA при наличии GPU, иначе ONNX на CPU - **ONNX на CPU по умолчанию** — CUDA и OpenVINO включаются явно
- **Батч-режим** — обработка нескольких файлов за один вызов - **Батч-режим** — обработка нескольких файлов за один вызов
- **Разделение говорящих** — локальная диаризация по флагу `--diarize` - **Разделение говорящих** — локальная диаризация по флагу `--diarize`
- **Из проводника Windows** — пункт Transcribe в меню «Отправить» ([установка](#контекстное-меню-проводника-windows)) - **Из проводника 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 powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" # Windows
``` ```
**2. Установить transcriber:** После установки откройте новую консоль, чтобы перечитались переменные окружения.
**2. Установить transcriber.** Напрямую из git, без скачивания исходников:
```bash ```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× быстрее ```bash
- **Windows**: `winget install -e --id Nvidia.CUDA --version 12.9` (от администратора), перезапустить терминал git clone <URL>
- **Linux / WSL2**: работает из коробки (нужен только драйвер: `nvidia-smi`) cd local-transcriber
- **Без NVIDIA GPU**: автоматически используется ONNX с GigaAM RNN-T на CPU. uv tool install --python 3.13 .
Эта модель понимает только русскую речь ```
- **Другие языки без 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`
**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 ```bash
transcribe meeting.mp4 transcribe meeting.mp4
@@ -63,7 +79,14 @@ transcribe meeting.mp4
**Обновление:** **Обновление:**
```bash ```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> </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 ```bash
# Простой запуск (CUDA medium или ONNX GigaAM RNN-T, язык ru) # Простой запуск (ONNX GigaAM RNN-T на CPU, язык ru)
transcribe meeting.mp4 transcribe meeting.mp4
# Указать язык # Указать язык
transcribe lecture.mp3 --language en transcribe lecture.mp3 --device cpu --model medium --language en
# Максимальное качество на NVIDIA GPU # Максимальное качество на NVIDIA GPU
transcribe podcast.wav --model large-v3 --compute-type float16 transcribe podcast.wav --device cuda --model large-v3 --compute-type float16
# Максимальное качество на Intel GPU # Максимальное качество на Intel GPU
transcribe podcast.wav --model large-v3 --device openvino-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) | Модель распознавания | | `--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` | Путь к выходному файлу | | `--output` | `-o` | `<файл>-transcript.md` | Путь к выходному файлу |
| `--device` | `-d` | `auto` | Устройство (auto, cpu, cuda, openvino, openvino-gpu, openvino-cpu, onnx) | | `--device` | `-d` | `auto` | Устройство (auto, cpu, cuda, openvino, openvino-gpu, openvino-cpu, onnx) |
| `--compute-type` | — | float16 (CUDA) / int8 (ONNX/OpenVINO) / float32 (CPU) | Тип вычислений | | `--compute-type` | — | float16 (CUDA) / int8 (ONNX/OpenVINO) / float32 (CPU) | Тип вычислений |
| `--threads` | `-t` | 0 (авто) | Потоки CPU (рекомендуется = число физ. ядер) | | `--threads` | `-t` | 0 (авто) | Потоки CPU (рекомендуется = число физ. ядер); для ONNX задаёт потоки и ASR, и VAD |
| `--diarize` / `--no-diarize` | — | `false` | Включить или отключить разделение на реплики говорящих | | `--diarize` / `--no-diarize` | — | `false` | Включить или отключить разделение на реплики говорящих |
| `--speakers` | — | авто | Ожидаемое число говорящих; включает диаризацию и несовместим с `--no-diarize` | | `--speakers` | — | авто | Ожидаемое число говорящих; включает диаризацию и несовместим с `--no-diarize` |
| `--force` | `-f` | — | Перезаписать существующие транскрипты | | `--force` | `-f` | — | Перезаписать существующие транскрипты |
| `--verbose` | `-v` | — | Подробный вывод | | `--verbose` | `-v` | — | Подробный вывод: сегменты по ходу, движок, потоки, версии runtime |
### Диагностика исполнения
Перед распознаванием программа печатает модель, устройство и тип вычислений,
которые выбраны фактически: после `auto` это `onnx`, после `openvino` —
`openvino-gpu` или `openvino-cpu`. Если после загрузки устройство оказалось
не тем, что выбрано до неё (например, OpenVINO взял CPU вместо GPU),
выводится строка `Запрошено ..., используется ...`.
С `--verbose` добавляются движок, бюджет потоков и версии runtime: для ONNX —
`onnxruntime`, `onnx-asr`, доступные providers и те, что заданы сессиям ASR
и VAD; для CUDA — `ctranslate2` и число видимых CUDA-устройств; для OpenVINO —
версия и доступные устройства. Этих строк достаточно, чтобы разобрать проблему
на чужой машине без доступа к записи: попросите прислать вывод с `--verbose`.
Наличие provider в списке доступных не означает, что распознавание идёт
на GPU или NPU: сессии получают только явно заданный список, сейчас это CPU.
## Платформы ## Платформы
@@ -235,7 +332,7 @@ transcribe --uninstall-menu
| CPU через ONNX | ✅ авто | ✅ авто | ✅ авто | | CPU через ONNX | ✅ авто | ✅ авто | ✅ авто |
| OpenVINO (x86 CPU) | ✅ явно | — | ✅ явно | | OpenVINO (x86 CPU) | ✅ явно | — | ✅ явно |
| OpenVINO (Intel GPU) | ✅ явно | — | ✅ явно | | OpenVINO (Intel GPU) | ✅ явно | — | ✅ явно |
| GPU (NVIDIA) | ✅ авто | — | ✅ (нужен CUDA 12) | | GPU (NVIDIA) | extra + явно (x86_64) | — | extra + явно (x64; прогон без Toolkit ожидается) |
Данные по macOS основаны на доступности пакетов onnxruntime: прогонов на этой Данные по macOS основаны на доступности пакетов onnxruntime: прогонов на этой
платформе не было. платформе не было.
@@ -244,9 +341,9 @@ transcribe --uninstall-menu
<summary>Linux / WSL2</summary> <summary>Linux / WSL2</summary>
- **Intel GPU** (Arc, встроенная графика) работает из коробки через OpenVINO - **Intel GPU** (Arc, встроенная графика) работает из коробки через OpenVINO
- **NVIDIA GPU** работает из коробки — cuBLAS ставится автоматически как зависимость - **NVIDIA GPU**: [extra `cuda` и явный выбор](#подключение-cuda), отдельно совместимый драйвер
- Нужен только драйвер NVIDIA (проверка: `nvidia-smi`) - `nvidia-smi` проверяет наличие драйвера, но не доказывает совместимость GPU с runtime
- На ARM (aarch64) cuBLAS через pip недоступен — нужен системный CUDA toolkit - CUDA extra на ARM (aarch64) этим проектом не поддерживается
</details> </details>
@@ -263,13 +360,9 @@ transcribe --uninstall-menu
- CPU работает из коробки - CPU работает из коробки
- **Intel GPU** (Arc, встроенная графика) работает из коробки через OpenVINO - **Intel GPU** (Arc, встроенная графика) работает из коробки через OpenVINO
- Для **NVIDIA GPU** нужен **CUDA 12** (ctranslate2 4.7 не совместим с CUDA 11 и 13): - **NVIDIA GPU**: [extra `cuda` и явный выбор](#подключение-cuda), отдельно совместимый драйвер
``` - Требуется Visual C++ Runtime x64; системный CUDA Toolkit не нужен для extra
winget install -e --id Nvidia.CUDA --version 12.9 - Реальная транскрипция с extra без Toolkit пока не проверена на Windows
```
> `winget install` требует запуска от имени администратора (elevated terminal).
> `uv tool install` работает без админа (ставит в пользовательскую директорию).
- После установки CUDA перезапустите терминал
</details> </details>
@@ -293,8 +386,8 @@ diarize = true
`--diarize` и `--no-diarize` позволяют переопределить `diarize` из конфига для `--diarize` и `--no-diarize` позволяют переопределить `diarize` из конфига для
отдельного запуска. отдельного запуска.
При `device = "auto"` выбирается CUDA, если доступен `nvidia-smi`, иначе ONNX. При `device = "auto"` всегда выбирается ONNX CPU, даже если доступны
Явный `device` из CLI или конфига отключает этот автоматический выбор. `nvidia-smi` и extra `cuda`. Другие варианты выбираются явно из CLI или конфига.
Каталоги моделей различаются между бэкендами, поэтому при закреплении `model` Каталоги моделей различаются между бэкендами, поэтому при закреплении `model`
в конфиге рекомендуется явно закрепить и совместимый `device`. То же с языком: в конфиге рекомендуется явно закрепить и совместимый `device`. То же с языком:
автоматический ONNX-профиль рассчитан на русскую речь, а для остальных языков автоматический ONNX-профиль рассчитан на русскую речь, а для остальных языков
@@ -311,9 +404,9 @@ diarize = true
## Модели и GPU ## Модели и GPU
Рекомендации: Рекомендации:
- **По умолчанию без CUDA:** ONNX `gigaam-v3-e2e-rnnt` — читаемый русский текст с - **По умолчанию:** ONNX `gigaam-v3-e2e-rnnt` — читаемый русский текст с
пунктуацией почти без потери скорости относительно сырого `gigaam-v3` пунктуацией почти без потери скорости относительно сырого `gigaam-v3`
- **Макс. качество (NVIDIA):** `large-v3` + `--compute-type float16` - **Макс. качество (NVIDIA):** `--device cuda --model large-v3 --compute-type float16`
- **Макс. качество (Intel GPU):** `large-v3` + `--device openvino-gpu` - **Макс. качество (Intel GPU):** `large-v3` + `--device openvino-gpu`
- **Макс. скорость CPU (русский):** `--device onnx --model gigaam-v3` (17-29× RTF, без пунктуации; рекомендуется LLM-нормализация терминов после) - **Макс. скорость 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; пунктуация может быть слабой) - **Быстрый OpenVINO с низким WER:** `--device openvino-cpu --model large-v3-turbo --compute-type int8` (7,2× RTFx на контрольном Intel CPU; пунктуация может быть слабой)
+6 -6
View File
@@ -49,7 +49,7 @@ transcribe <путь_к_файлу> [опции]
--output, -o Путь к выходному файлу --output, -o Путь к выходному файлу
По умолчанию: <input_stem>-transcript.md По умолчанию: <input_stem>-transcript.md
--device, -d Устройство (auto|cpu|cuda|onnx|openvino|openvino-gpu|openvino-cpu) --device, -d Устройство (auto|cpu|cuda|onnx|openvino|openvino-gpu|openvino-cpu)
По умолчанию: auto (CUDA при наличии, иначе ONNX CPU) По умолчанию: auto (всегда ONNX CPU)
--compute-type Тип вычислений --compute-type Тип вычислений
По умолчанию: float16 (CUDA) / int8 (ONNX) По умолчанию: float16 (CUDA) / int8 (ONNX)
--verbose, -v Подробный вывод (прогресс сегментов) --verbose, -v Подробный вывод (прогресс сегментов)
@@ -103,7 +103,7 @@ transcribe <путь_к_файлу> [опции]
|---------------------------|---------------------------------------| |---------------------------|---------------------------------------|
| RTX 3060 + large-v3 | ~10-15x (1 час аудио ≈ 4-6 мин) | | RTX 3060 + large-v3 | ~10-15x (1 час аудио ≈ 4-6 мин) |
| RTX 4050 + large-v3 | ~12-18x (1 час аудио ≈ 3-5 мин) | | 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 + ONNX GigaAM RNN-T | ~10-14x (1 час аудио ≈ 4-6 мин) |
| CPU + OpenVINO Turbo INT8 | ~7-10x (1 час аудио ≈ 6-9 мин) | | CPU + OpenVINO Turbo INT8 | ~7-10x (1 час аудио ≈ 6-9 мин) |
| CPU (modern) + large-v3 | ~0.5-1x (1 час аудио ≈ 60-120 мин) | | CPU (modern) + large-v3 | ~0.5-1x (1 час аудио ≈ 60-120 мин) |
@@ -115,7 +115,7 @@ transcribe <путь_к_файлу> [опции]
|-------------------|-------|--------------------------|----------------------------|-------------------------| |-------------------|-------|--------------------------|----------------------------|-------------------------|
| RTX 3060 | 6 GB | ✅ | ✅ (впритык) | int8 — безопасный выбор | | RTX 3060 | 6 GB | ✅ | ✅ (впритык) | int8 — безопасный выбор |
| RTX 4050 | 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 | | Без GPU | — | ONNX GigaAM INT8 | — | auto выбирает ONNX |
Device-aware дефолты выбирают `float16` для CUDA и `int8` для ONNX/OpenVINO. Device-aware дефолты выбирают `float16` для CUDA и `int8` для ONNX/OpenVINO.
@@ -123,7 +123,7 @@ Device-aware дефолты выбирают `float16` для CUDA и `int8` д
### 4.2. Требования к окружению ### 4.2. Требования к окружению
- Python ≥ 3.13 - 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 и модели - Дисковое пространство для моделей: зависит от выбранного backend и модели
- Выходные файлы: UTF-8 (явная кодировка при записи) - Выходные файлы: UTF-8 (явная кодировка при записи)
@@ -177,9 +177,9 @@ local-transcriber/
| Качество распознавания русского текста | Среднее | large-v3 хорошо справляется с ru; при проблемах — попробовать `--language ru` вместо auto | | Качество распознавания русского текста | Среднее | large-v3 хорошо справляется с ru; при проблемах — попробовать `--language ru` вместо auto |
| Нет разделения по спикерам | Низкое | Осознанно выведено за скоуп MVP; добавление diarization (pyannote.audio) — возможное расширение | | Нет разделения по спикерам | Низкое | Осознанно выведено за скоуп MVP; добавление diarization (pyannote.audio) — возможное расширение |
| Первый запуск: долгая загрузка модели | Низкое | Прогресс-бар при скачивании; модели кешируются в `~/.cache/huggingface/` | | Первый запуск: долгая загрузка модели | Низкое | Прогресс-бар при скачивании; модели кешируются в `~/.cache/huggingface/` |
| CUDA несовместимость на Windows | Среднее | Автоматический fallback на CPU + предупреждение; в README — инструкция по CUDA | | CUDA несовместимость на Windows | Среднее | Явная CUDA сохраняет причину ошибки; пользователь выбирает CPU или исправляет окружение по README |
| Большие файлы (>2 часов) | Среднее | Учитывать память выбранного backend; чанкование рассматривается отдельно | | Большие файлы (>2 часов) | Среднее | Учитывать память выбранного backend; чанкование рассматривается отдельно |
| OOM на GPU с 4 GB VRAM | Среднее | CUDA использует float16; при OOM — fallback на CPU с предупреждением или явный более лёгкий профиль | | OOM на GPU с 4 GB VRAM | Среднее | CUDA использует float16; при OOM явной CUDA — ошибка с подсказкой выбрать меньшую модель или CPU |
| Файл без речи (тишина, музыка, шум) | Низкое | Создаётся транскрипт с шапкой метаданных и `*Речь не обнаружена.*` в теле + предупреждение в stderr | | Файл без речи (тишина, музыка, шум) | Низкое | Создаётся транскрипт с шапкой метаданных и `*Речь не обнаружена.*` в теле + предупреждение в stderr |
## 7. Вне скоупа MVP ## 7. Вне скоупа MVP
+108 -52
View File
@@ -1,78 +1,134 @@
# ADR-001: Preload libcublas из pip-пакета для GPU на Linux/WSL2 # ADR-001: Необязательная CUDA и загрузка cuBLAS из окружения
**Статус**: Принято **Статус**: Принято; пересмотр обязательной cuBLAS от 2026-03-18
**Дата**: 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")`, CTranslate2 загружает cuBLAS динамически и не поставляет её в своём wheel.
но не бандлит эту библиотеку в свой wheel — ожидает её в системе. Прежняя обязательная зависимость добавляла около 581 MB скачивания на Linux
Без установленного CUDA toolkit `uv run transcribe --device cuda` падает с ошибкой. x86_64, даже если пользователь работал только через ONNX CPU.
Наличие `nvidia-smi` доказывает наличие утилиты драйвера, но не совместимость GPU
с runtime. Автоматический выбор CUDA мог приводить к ошибке на старом GPU.
Ключевые факты: Прежнее утверждение о недоступности Windows wheel `nvidia-cublas-cu12` было
- `libcuda.so.1` приходит от NVIDIA driver (всегда есть, если GPU есть) ошибочным: такой wheel есть и для закреплённой версии 12.9.1.4. Следовательно,
- `libcublas.so.12` отсутствует в wheel ctranslate2 на обеих платформах системный CUDA Toolkit не должен быть обязательным способом подключения Windows.
- **cuDNN не нужен** — в `libctranslate2.so` ноль символов cudnn (проверено `nm -D` и `strings`)
- На Windows ctranslate2 делает `os.add_dll_directory` в своём `__init__.py`,
но только для DLL внутри пакета; `cublas64_12.dll` тоже не бандлится
## Решение ## Решение
### 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`: CTranslate2 4.8.1 и cuBLAS 12.9.1.4 закреплены в `pyproject.toml`, чтобы
``` установка через `uv tool` и из клона использовала проверенное сочетание.
"nvidia-cublas-cu12>=12.4; sys_platform == 'linux' and platform_machine == 'x86_64'" В cuBLAS 12.9.2.10 появилась дополнительная зависимость NVRTC. Обновление
``` версий требует повторной проверки библиотек и bootstrap; одного обновления
lock-файла недостаточно.
Нижняя граница `>=12.4` — ctranslate2 собран с CUDA 12.4. Пакет доступен только ### Linux: preload по полному пути
для Linux x86_64 (на Windows и macOS не устанавливается по platform marker).
### 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` по полному пути через ### Windows: регистрация каталога и preload DLL
`ctypes.CDLL(path, mode=RTLD_GLOBAL)` **до** первого `import ctranslate2`.
Почему не `os.environ["LD_LIBRARY_PATH"]`: на Linux/glibc динамический линкер (`ld.so`) Bootstrap регистрирует `nvidia/cublas/bin` через `os.add_dll_directory`, затем
кеширует пути поиска при старте процесса и **не перечитывает** `LD_LIBRARY_PATH` загружает по абсолютным путям сначала `cublasLt64_12.dll`, затем
из environ в рамках уже запущенного процесса. `cublas64_12.dll`. Handles библиотек и регистрации каталога хранятся до
завершения процесса. Повторный вызов не загружает их заново. Это обеспечивает
доступность уже загруженных DLL для динамического загрузчика CTranslate2 и
не требует изменения системного PATH.
Почему `RTLD_GLOBAL`: без этого флага символы cublas не видны другим `.so`, Bootstrap вызывается только при создании CUDA-модели, до локального импорта
загруженным позже (в т.ч. `libctranslate2.so`). `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. Прежний механизм fallback оркестратора при `strict_device=False` сохранён
`--device auto` → `strict_device=False` → текущее поведение с fallback на CPU. для существующих программных 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, - `ctranslate2-4.8.1-cp313-cp313-win_amd64.whl`, SHA-256
включая машины без GPU. Bootstrap при этом preload'ит libcublas (overhead ~1 ms), `d52499f05a60a791aeadee28d609efa130142f376d1ea76b2b1c593bb01f8827`.
но она не используется, т.к. ctranslate2 не получит запрос на CUDA device. `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`, Основания: [CTranslate2 4.8.1 на PyPI](https://pypi.org/project/ctranslate2/4.8.1/#files),
но тогда теряется zero-config UX. Для v1 оставляем как обязательную зависимость. [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. Это обоснование состава extra для конкретных wheels, а не подтверждение
Единственный путь — системный CUDA toolkit (`choco install cuda` / `winget install -e --id Nvidia.CUDA`). транскрипции на Windows GPU. Реальный прогон Windows x64 с драйвером и extra,
CLI выводит эту подсказку при CUDA-ошибке на `sys.platform == "win32"`. без 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` возвращает её в план установки.
| Альтернатива | Почему отклонена | Метаданные собранного wheel проверены для 12 сочетаний платформы, архитектуры
|---|---| и extra. Полное разрешение зависимостей wheel через `uv pip compile` для Linux
| `nvidia-cudnn-cu12` в зависимостях | ctranslate2 не использует cuDNN — ноль символов, проверено через `nm -D` | и Windows, с extra и без, подтверждает отсутствие NVIDIA-пакетов в обычной
| Self-reexec с `LD_LIBRARY_PATH` | ctypes.CDLL решает задачу без перезапуска процесса; self-reexec создаёт проблемы с сигналами, tty, fd | установке и наличие только cuBLAS 12.9.1.4 при выборе extra.
| `os.environ["LD_LIBRARY_PATH"] += ...` | glibc кеширует пути при старте, не перечитывает environ |
| `DeviceResolution` dataclass + `resolve_device()` | Один `bool strict_device` решает ту же задачу проще | Формы `uv tool install` для клона, клона с extra и именованной Git-зависимости
| ctranslate2 preflight (`get_supported_compute_types`) | Латентность; try/catch при загрузке модели не хуже | с extra проверены в отдельном временном каталоге tools. Для проверки самой
| Bootstrap console_scripts entrypoint | Не нужен — достаточно вызова в начале `transcriber.py` | команды зависимости были исключены; их разрешение проверено отдельно выше.
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 в эту задачу не входит.
+24 -16
View File
@@ -2,6 +2,7 @@
**Статус**: Принято **Статус**: Принято
**Дата**: 2026-03-18 **Дата**: 2026-03-18
**Обновлено**: 2026-09-21 — решения 2 и 4 пересмотрены (см. пометки)
## Контекст ## Контекст
@@ -17,19 +18,23 @@
**Почему**: загрузка модели (large-v3) занимает ~10 секунд и ~3 GB RAM/VRAM. Повторный запуск по уже обработанным файлам должен быть дешёвым no-op. **Почему**: загрузка модели (large-v3) занимает ~10 секунд и ~3 GB RAM/VRAM. Повторный запуск по уже обработанным файлам должен быть дешёвым no-op.
### 2. TranscribeFileResult: возврат обновлённого состояния модели ### 2. Состояние модели между файлами
При mid-stream CUDA fallback `_transcribe_file()` перезагружает модель на CPU внутри себя. Чтобы следующие файлы в батче не грузили модель повторно, результат включает обновлённые `model` и `actual_device`. **Пересмотрено 2026-09-21.** Исходное решение: `_transcribe_file()` возвращал
`TranscribeFileResult` с обновлёнными `model`, `backend`, `model_path`
и `actual_device`, а CLI переносил их в следующий файл после mid-stream
fallback. Это делало caller владельцем согласованности четырёх полей.
```python Теперь состояние не покидает module выполнения: `Transcriber` создаётся один
@dataclass раз на запуск после prescan, загружает модель при первом файле и сам держит
class TranscribeFileResult: загруженную модель, adapter и фактическое исполнение. Fallback меняет
result: TranscribeResult состояние внутри module, и следующий файл батча идёт уже по нему. Caller
model: WhisperModel # может измениться при fallback получает `TranscribeResult` и `ExecutionInfo` (запрошенное и выбранное
actual_device: str # может измениться при fallback исполнение, движок, модель, квантизация, потоки, версии runtime).
```
**Альтернатива**: передавать модель по ссылке через mutable контейнер — менее явно и сложнее тестировать. **Альтернатива**: mutable-контейнер с моделью, передаваемый по ссылке, —
отклонена тогда же; owner-объект решает ту же задачу явно и тестируется
через свой interface.
### 3. Конфиг: CWD → глобальный, без мержа ### 3. Конфиг: CWD → глобальный, без мержа
@@ -43,15 +48,18 @@ class TranscribeFileResult:
**Приоритет значений**: CLI > конфиг > хардкод. **Приоритет значений**: CLI > конфиг > хардкод.
### 4. `_transcribe_file()` — внутренний helper ### 4. Публичный `transcribe()` — путь одного файла
Публичный API (`transcribe()`) сохранён без изменений. Новая функция `_transcribe_file()` — внутренний helper с префиксом `_`, не часть публичного контракта. **Пересмотрено 2026-09-21.** Внутренние helpers `load_model()`
и `_transcribe_file()` удалены. `transcribe()` сохранён и стал обёрткой над
`transcribe()` стала тонкой обёрткой: `load_model()` + `_transcribe_file()` → `TranscribeResult`. тем же `Transcriber`: `model_name` и `compute_type` без значения берут
умолчания выбранного исполнения, `device="auto"` означает ONNX CPU, как в CLI.
## Последствия ## Последствия
- Обратная совместимость CLI: `transcribe file.mp4` работает как раньше - Обратная совместимость CLI: `transcribe file.mp4` работает как раньше
- Все существующие тесты проходят без изменений сигнатур - Batch mode: модель загружается один раз для всех файлов; CLI не переносит
- Batch mode: модель загружается один раз для всех файлов состояние между файлами
- Fallback из CLI недостижим: явный device — strict, `auto` — ONNX CPU без
переходов. Механизм сохранён для `transcribe(..., strict_device=False)`
- `--force` флаг для перезаписи существующих транскриптов в батч-режиме - `--force` флаг для перезаписи существующих транскриптов в батч-режиме
+34 -15
View File
@@ -2,7 +2,7 @@
**Статус**: Принято **Статус**: Принято
**Дата**: 2026-03-21 **Дата**: 2026-03-21
**Обновлено**: 2026-08-12 **Обновлено**: 2026-09-21
## Контекст ## Контекст
@@ -32,8 +32,9 @@ Protocol вместо ABC — бэкенды не наследуются, дос
### Ленивые импорты ### Ленивые импорты
Бэкенды импортируются только при выборе — `get_backend(device)` делает import внутри. Бэкенды импортируются только при выборе — `get_backend(device)` делает import внутри.
Импорт faster-whisper запускает CUDA bootstrap (~1ms), импорт openvino-genai загружает ~50MB CUDA bootstrap вызывается только при создании CUDA-модели (см. ADR-001).
shared libraries. Ни то, ни другое не должно происходить, если бэкенд не выбран. Импорт openvino-genai загружает ~50MB shared libraries и не должен происходить,
если бэкенд не выбран.
### Device как селектор бэкенда ### Device как селектор бэкенда
@@ -41,23 +42,41 @@ shared libraries. Ни то, ни другое не должно происхо
- `cuda`, `cpu` → FasterWhisperBackend - `cuda`, `cpu` → FasterWhisperBackend
- `openvino` → OpenVINOBackend - `openvino` → OpenVINOBackend
- `onnx` → OnnxAsrBackend - `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 в одном вызове. Обновлено 2026-09-21. `Transcriber(ExecutionRequest)` в `transcriber.py`
CLI не вызывает ensure_model_available отдельно — это убирает двойной resolution разрешает `auto` и device-aware умолчания, выполняет ensure_model_available +
и гарантирует, что модель скачивается для правильного бэкенда. create_model в одном месте и держит модель, adapter и фактическое исполнение
на весь запуск. CLI не вызывает `get_backend` и не хранит handles.
Внутри module различаются три вещи: поддерживаемая модель, движок
распознавания (`faster-whisper`, `openvino`, `onnx-asr`) и способ исполнения.
Значение `device` из CLI/TOML сохраняется как вход: `get_backend` выбирает
по нему adapter, adapter называет свой движок атрибутом `engine`, а
аппаратную часть (CPU, CUDA, Intel GPU, список providers) трактует сам.
Новое исполнение — новое значение `device` в `get_backend` и его понимание
в adapter'е, не новая иерархия adapters.
### Cross-backend fallback ### Cross-backend fallback
Fallback живёт в `transcriber.py` (оркестратор), не в бэкендах: Fallback живёт внутри `Transcriber`, не в бэкендах и не в CLI:
- CUDA ошибка → CPU (FasterWhisper) - CUDA ошибка → CPU (FasterWhisper)
- OpenVINO ошибка → CPU (FasterWhisper) - OpenVINO ошибка → CPU (FasterWhisper)
- `strict_device=True` (явный `--device`) → ошибка без fallback - strict запуск (явный device из CLI или TOML) → ошибка без fallback
При fallback в батч-режиме обновляются model, backend, model_path и actual_device После перехода module сам держит новое состояние; следующий файл батча
через TranscribeFileResult — следующий файл использует правильный бэкенд. использует его без участия caller. `WordTimestampsUnavailableError`
и ошибки пользовательских данных переходом не считаются.
### Диагностика исполнения
Каждый adapter отдаёт `runtime_info()` — версии runtime и фактическую
конфигурацию (для ONNX — доступные providers и те, что заданы сессиям ASR
и VAD, квантизация, бюджет потоков). `Transcriber` включает их в
`ExecutionInfo`; CLI печатает их в `--verbose`. Наличие provider в wheel не
считается доказательством выполнения графа на GPU/NPU.
### Аудио для OpenVINO ### Аудио для OpenVINO
@@ -76,13 +95,13 @@ OpenVINO модели предквантизированы (int8/fp16), compute_
### Зависимости бэкендов по умолчанию ### Зависимости бэкендов по умолчанию
faster-whisper, onnx-asr/onnxruntime и openvino-genai ставятся вместе. Модели faster-whisper, onnx-asr/onnxruntime и openvino-genai ставятся вместе. Модели
скачиваются только для активного бэкенда. CUDA (`nvidia-cublas-cu12`) остаётся скачиваются только для активного бэкенда. cuBLAS подключается через extra `cuda`
conditional для Linux x86_64. OpenVINO — conditional для x86_64/AMD64, кроме для Linux/WSL x86_64 и Windows x64 (см. ADR-001). OpenVINO — conditional для x86_64/AMD64, кроме
macOS; ONNX обеспечивает автоматический CPU-путь на остальных платформах. macOS; ONNX обеспечивает автоматический CPU-путь на остальных платформах.
## Последствия ## Последствия
- Обратная совместимость: `transcribe()` сохранён; `load_model()` изменил сигнатуру (возвращает 4-tuple вместо 2-tuple, добавлен `compute_type_explicit`) - Обратная совместимость: `transcribe()` сохранён; `load_model()` и `_transcribe_file()` удалены (2026-09-21)
- Новый бэкенд добавляется одним файлом в `backends/` + регистрацией в `__init__.py` - Новый бэкенд добавляется одним файлом в `backends/` + регистрацией в `__init__.py`
- Модели скачиваются по запросу — CUDA пользователь не качает OpenVINO модели, и наоборот - Модели скачиваются по запросу — CUDA пользователь не качает OpenVINO модели, и наоборот
- ARM и macOS: OpenVINO не ставится (platform markers), auto использует ONNX - ARM и macOS: OpenVINO не ставится (platform markers), auto использует ONNX
+5 -3
View File
@@ -102,10 +102,12 @@ Mm-hmm-редукция и иностранные вставки **не обна
## Решение ## Решение
**Принять onnx-asr как CPU-бэкенд автоматического профиля. **Принять onnx-asr как CPU-бэкенд автоматического профиля.
GigaAM v3 E2E RNN-T — модель по умолчанию на машинах без CUDA.** GigaAM v3 E2E RNN-T — модель автоматического профиля на всех машинах.**
Порядок `--device auto`: CUDA при наличии `nvidia-smi`, иначе ONNX. OpenVINO и С 2026-09-21 `--device auto` всегда выбирает ONNX CPU; для ASR и VAD явно
FasterWhisper CPU остаются доступными через явный CLI-аргумент или конфиг. передаётся `CPUExecutionProvider`, независимо от доступных providers. CUDA,
OpenVINO и FasterWhisper CPU доступны через явный CLI-аргумент или конфиг.
Подключение CUDA описано в [ADR-001](001-cuda-bootstrap.md).
GigaAM понимает только русскую речь, поэтому для остальных языков автоматический GigaAM понимает только русскую речь, поэтому для остальных языков автоматический
профиль не годится — нужен явный Whisper. Несовместимый язык работу не профиль не годится — нужен явный Whisper. Несовместимый язык работу не
-1
View File
@@ -5,7 +5,6 @@
- Основной remote: `origin` - Основной remote: `origin`
- Gitea: `https://git.dementev.space` - Gitea: `https://git.dementev.space`
- CLI: `tea` - CLI: `tea`
- Remote `github` является зеркалом и не используется для управления задачами
- Внешние pull request не входят в очередь triage - Внешние 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
View File
@@ -2,7 +2,7 @@
## Режимы `--device` ## Режимы `--device`
- `auto` (по умолчанию) — CUDA при наличии `nvidia-smi`, иначе ONNX на CPU - `auto` (по умолчанию) — всегда ONNX на CPU
- `cuda` — строго NVIDIA GPU, ошибка если недоступен - `cuda` — строго NVIDIA GPU, ошибка если недоступен
- `openvino` — авто-выбор OpenVINO GPU или CPU - `openvino` — авто-выбор OpenVINO GPU или CPU
- `openvino-gpu` — строго Intel GPU через OpenVINO - `openvino-gpu` — строго Intel GPU через OpenVINO
@@ -13,8 +13,8 @@
| Оборудование | Рекомендуемый `--device` | Бэкенд | Ожидаемая скорость | | Оборудование | Рекомендуемый `--device` | Бэкенд | Ожидаемая скорость |
|---|---|---|---| |---|---|---|---|
| NVIDIA GPU (6+ GB VRAM) | `auto` / `cuda` | faster-whisper (CTranslate2) | 7-19x реалтайм | | NVIDIA GPU (6+ GB VRAM) | `cuda` + extra | faster-whisper (CTranslate2) | 7-19x реалтайм |
| Любой CPU без NVIDIA | `auto` / `onnx` | ONNX GigaAM RNN-T | 10-14x реалтайм* | | Любой CPU | `auto` / `onnx` | ONNX GigaAM RNN-T | 10-14x реалтайм* |
| Intel Arc iGPU / dGPU | `openvino-gpu` | OpenVINO GenAI (GPU) | TBD | | Intel Arc iGPU / dGPU | `openvino-gpu` | OpenVINO GenAI (GPU) | TBD |
| Intel/AMD x86 CPU | `openvino-cpu` | OpenVINO GenAI (CPU) | 3-10x реалтайм* | | Intel/AMD x86 CPU | `openvino-cpu` | OpenVINO GenAI (CPU) | 3-10x реалтайм* |
| Любой CPU, FasterWhisper | `cpu` | faster-whisper (CTranslate2) | ~1.5x реалтайм | | Любой CPU, FasterWhisper | `cpu` | faster-whisper (CTranslate2) | ~1.5x реалтайм |
@@ -22,7 +22,7 @@
\* По результатам контрольных прогонов на Intel и AMD CPU. Реальная скорость \* По результатам контрольных прогонов на Intel и AMD CPU. Реальная скорость
зависит от CPU, модели и записи. зависит от CPU, модели и записи.
Автоматический профиль без NVIDIA рассчитан на русскую речь: GigaAM других Автоматический профиль рассчитан на русскую речь: GigaAM других
языков не понимает. Для них берите Whisper — `openvino-cpu` на x86 или `cpu` языков не понимает. Для них берите 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` подтягивается при установке). Драйвер NVIDIA устанавливается отдельно. Полный системный CUDA Toolkit не
Дополнительных шагов не требуется. нужен для пути через extra; на Windows требуется Visual C++ Runtime x64.
Состав Windows DLL проверен статически; реальная транскрипция без Toolkit
> На ARM (aarch64) cuBLAS через pip недоступен — нужен системный CUDA toolkit. пока не проверена. Версии и границы проверки — [ADR-001](adr/001-cuda-bootstrap.md).
CUDA extra для ARM в этой задаче не поддерживается.
### Windows
Нужен системный **CUDA 12** (ctranslate2 4.7 не совместим с CUDA 11 и 13):
```bash
winget install -e --id Nvidia.CUDA --version 12.9 # требует запуска от имени администратора
```
После установки перезапустите терминал.
## Совместимость GPU ## Совместимость GPU
@@ -331,14 +325,19 @@ threshold не помогла — проблема inherent для модели
Убедитесь, что `nvidia-smi` возвращает информацию о GPU. Драйвер NVIDIA предоставляет `libcuda.so.1`, Убедитесь, что `nvidia-smi` возвращает информацию о GPU. Драйвер NVIDIA предоставляет `libcuda.so.1`,
без которого CUDA не работает — его нельзя поставить через pip. без которого CUDA не работает — его нельзя поставить через pip.
### Windows: ошибка при загрузке модели на GPU ### Отсутствующие библиотеки и несовместимый GPU
GPU на Windows требует **CUDA 12** (ctranslate2 4.7 не совместим с CUDA 11 и 13). Установите: При отсутствии cuBLAS подключите extra в том окружении, откуда запускается
```bash программа, по [инструкции README](../README.md#подключение-cuda).
winget install -e --id Nvidia.CUDA --version 12.9 # требует запуска от имени администратора На Windows ошибка загрузки DLL может также означать отсутствие Visual C++ Runtime.
```
После установки перезапустите терминал. `no kernel image` / `invalid device function` означают несовместимость GPU
с выбранной сборкой runtime. Установка extra её не исправит. Ошибки драйвера
требуют проверки драйвера; нехватка VRAM — меньшей модели или явного выбора CPU.
Исходная ошибка сохраняется. CLI/TOML с явной CUDA не переходят молча на CPU
ни при загрузке, ни при распознавании.
### ARM (aarch64) ### 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
View File
@@ -8,16 +8,23 @@ dependencies = [
"typer>=0.24.1,<1", "typer>=0.24.1,<1",
"rich>=14.3.3,<15", "rich>=14.3.3,<15",
"faster-whisper>=1.2.1,<2", "faster-whisper>=1.2.1,<2",
# CUDA-зависимости Windows проверены для этой сборки; см. ADR-001.
"ctranslate2==4.8.1",
"httpx>=0.28,<1", "httpx>=0.28,<1",
"socksio>=1.0.0,<2", "socksio>=1.0.0,<2",
# При обновлении сверить блок tool.uv.dependency-metadata ниже. # При обновлении сверить блок tool.uv.dependency-metadata ниже.
"sherpa-onnx==1.13.6", "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')", "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", "onnx-asr[cpu,hub]>=0.12,<0.13",
"onnxruntime>=1.28,<2", "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] [project.scripts]
transcribe = "local_transcriber.cli:app" transcribe = "local_transcriber.cli:app"
+28 -7
View File
@@ -1,12 +1,14 @@
""" """
Preload CUDA-библиотек из pip-пакетов до импорта ctranslate2. Подготовка CUDA-библиотек из extra перед созданием CUDA-модели.
Проблема: ctranslate2 на Linux делает dlopen("libcublas.so.12"), Проблема: ctranslate2 на Linux делает dlopen("libcublas.so.12"),
но не знает, что библиотека лежит внутри pip-пакета nvidia-cublas-cu12. но не знает, что библиотека лежит внутри 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 Решение: загружаем libcublas.so.12 по полному пути через ctypes.CDLL
с флагом RTLD_GLOBAL до первого import ctranslate2. Динамический линкер с флагом RTLD_GLOBAL до создания CUDA-модели. Динамический линкер
кеширует загруженные библиотеки по soname — когда ctranslate2 потом кеширует загруженные библиотеки по soname — когда ctranslate2 потом
вызовет dlopen("libcublas.so.12"), линкер вернёт уже загруженный handle. вызовет dlopen("libcublas.so.12"), линкер вернёт уже загруженный handle.
@@ -21,20 +23,24 @@ import glob
import os import os
import sys import sys
# Закрытие регистрации удаляет каталог из поиска DLL, поэтому храним handles.
_dll_directories: dict[str, object] = {}
_dll_libraries: dict[str, object] = {}
def ensure_cublas_loadable() -> None: def ensure_cublas_loadable() -> None:
"""Загружает libcublas из nvidia-cublas-cu12 в адресное пространство процесса. """Загружает cuBLAS из nvidia-cublas-cu12 в адресное пространство процесса.
Вызывать ДО первого import ctranslate2. Вызывать перед созданием CUDA-модели; CPU-путь не требует bootstrap.
Безопасно вызывать многократно и на платформах без nvidia-cublas-cu12. Безопасно вызывать многократно и на платформах без nvidia-cublas-cu12.
""" """
if sys.platform != "linux": if sys.platform not in ("linux", "win32"):
return return
try: try:
import nvidia.cublas # type: ignore[import-untyped] import nvidia.cublas # type: ignore[import-untyped]
except ImportError: except ImportError:
# nvidia-cublas-cu12 не установлен (Windows, macOS, или CPU-only setup) # Optional-пакет не установлен; системная cuBLAS всё ещё может работать.
return return
# nvidia.cublas может быть namespace package (__file__ == None), # nvidia.cublas может быть namespace package (__file__ == None),
@@ -42,6 +48,21 @@ def ensure_cublas_loadable() -> None:
cublas_paths = getattr(nvidia.cublas, "__path__", None) cublas_paths = getattr(nvidia.cublas, "__path__", None)
if not cublas_paths: if not cublas_paths:
return 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") cublas_lib_dir = os.path.join(cublas_paths[0], "lib")
if not os.path.isdir(cublas_lib_dir): if not os.path.isdir(cublas_lib_dir):
return return
+7
View File
@@ -16,6 +16,9 @@ class Backend(Protocol):
наследование не требуется. наследование не требуется.
""" """
engine: str
"""Имя движка для сведений о выполнении."""
@property @property
def word_timestamps_available(self) -> bool: def word_timestamps_available(self) -> bool:
"""Гарантирует ли выбранный backend/model пословные таймкоды.""" """Гарантирует ли выбранный backend/model пословные таймкоды."""
@@ -53,3 +56,7 @@ class Backend(Protocol):
) -> TranscribeResult: ) -> TranscribeResult:
"""Транскрибирует файл, возвращает результат.""" """Транскрибирует файл, возвращает результат."""
... ...
def runtime_info(self) -> dict[str, str]:
"""Версии runtime и фактическая конфигурация для удалённой диагностики."""
...
@@ -6,21 +6,17 @@ from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import Any 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 from local_transcriber._cuda_bootstrap import ensure_cublas_loadable
from local_transcriber.types import (
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
Segment, Segment,
TranscribeResult, TranscribeResult,
Word, Word,
WordTimestampsUnavailableError, WordTimestampsUnavailableError,
) )
from local_transcriber.utils import package_version
MODEL_REPOS = { MODEL_REPOS = {
"tiny": "Systran/faster-whisper-tiny", "tiny": "Systran/faster-whisper-tiny",
@@ -48,10 +44,12 @@ MODEL_REQUIRED_FILES = [
class FasterWhisperBackend: class FasterWhisperBackend:
"""Бэкенд транскрипции через faster-whisper (CTranslate2).""" """Бэкенд транскрипции через faster-whisper (CTranslate2)."""
engine = "faster-whisper"
word_timestamps_available = True word_timestamps_available = True
def __init__(self): def __init__(self):
self.actual_compute_type: str | None = None self.actual_compute_type: str | None = None
self._cpu_threads = 0
def ensure_model_available( def ensure_model_available(
self, self,
@@ -94,6 +92,12 @@ class FasterWhisperBackend:
cpu_threads: число потоков для CPU inference (0 = дефолт библиотеки, обычно 4). cpu_threads: число потоков для CPU inference (0 = дефолт библиотеки, обычно 4).
""" """
if device == "cuda":
ensure_cublas_loadable()
from faster_whisper import WhisperModel
self._cpu_threads = cpu_threads
try: try:
return WhisperModel( return WhisperModel(
model_path, model_path,
@@ -157,6 +161,22 @@ class FasterWhisperBackend:
words=words, words=words,
) )
def runtime_info(self) -> dict[str, str]:
"""Версии CTranslate2/faster-whisper и число видимых CUDA-устройств."""
import ctranslate2
try:
cuda_devices = str(ctranslate2.get_cuda_device_count())
except Exception as exc: # noqa: BLE001 — диагностика не должна ронять запуск
cuda_devices = f"недоступно ({exc})"
return {
"faster_whisper": package_version("faster-whisper"),
"ctranslate2": package_version("ctranslate2"),
"cuda_devices": cuda_devices,
"compute_type": self.actual_compute_type or "",
"cpu_threads": str(self._cpu_threads) if self._cpu_threads else "по умолчанию",
}
def _notify(on_status: Callable[[str], None] | None, message: str) -> None: def _notify(on_status: Callable[[str], None] | None, message: str) -> None:
if on_status is not None: if on_status is not None:
+46 -2
View File
@@ -15,6 +15,7 @@ from local_transcriber.types import (
Word, Word,
WordTimestampsUnavailableError, WordTimestampsUnavailableError,
) )
from local_transcriber.utils import package_version
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -131,6 +132,8 @@ def _normalize_quantization(compute_type: str) -> str | None:
class OnnxAsrBackend: class OnnxAsrBackend:
"""Бэкенд транскрипции через onnx-asr (ONNX Runtime).""" """Бэкенд транскрипции через onnx-asr (ONNX Runtime)."""
engine = "onnx-asr"
def __init__(self, compute_type_explicit: bool = True): def __init__(self, compute_type_explicit: bool = True):
self._compute_type_explicit = compute_type_explicit self._compute_type_explicit = compute_type_explicit
self.actual_compute_type: str | None = None self.actual_compute_type: str | None = None
@@ -138,6 +141,9 @@ class OnnxAsrBackend:
self._model_name: str | None = None self._model_name: str | None = None
self._model_spec: OnnxModelSpec | None = None self._model_spec: OnnxModelSpec | None = None
self._vad: Any = None self._vad: Any = None
self._providers: list[str] = []
self._quantization: str | None = None
self._cpu_threads = 0
@property @property
def word_timestamps_available(self) -> bool: def word_timestamps_available(self) -> bool:
@@ -191,20 +197,49 @@ class OnnxAsrBackend:
compute_type маппится в onnx-asr ``quantization`` — это суффикс файла compute_type маппится в onnx-asr ``quantization`` — это суффикс файла
модели; для unquantized (float32/fp32) нужно None, не строку. модели; для unquantized (float32/fp32) нужно None, не строку.
cpu_threads > 0 задаёт intra_op_num_threads сессиям ASR и VAD;
0 оставляет настройки потоков onnxruntime.
""" """
import onnx_asr import onnx_asr
actual_compute_type = self.actual_compute_type or compute_type actual_compute_type = self.actual_compute_type or compute_type
quantization = _normalize_quantization(actual_compute_type) quantization = _normalize_quantization(actual_compute_type)
providers = ["CPUExecutionProvider"]
session_kwargs: dict[str, Any] = {"providers": providers}
if cpu_threads > 0:
session_kwargs["sess_options"] = _session_options(cpu_threads)
model = onnx_asr.load_model( model = onnx_asr.load_model(
model=model_path, model=model_path,
quantization=quantization, quantization=quantization,
**session_kwargs,
) )
vad = onnx_asr.load_vad("silero") vad = onnx_asr.load_vad("silero", **session_kwargs)
self._vad = vad self._vad = vad
self._providers = providers
self._quantization = quantization
self._cpu_threads = cpu_threads
return model.with_vad(vad).with_timestamps() return model.with_vad(vad).with_timestamps()
def runtime_info(self) -> dict[str, str]:
"""Версии ONNX Runtime/onnx-asr и providers, заданные сессиям ASR и VAD.
Доступность provider в wheel не означает, что граф выполняется на нём:
сессии получают только явно заданный список.
"""
import onnxruntime
configured = ", ".join(self._providers)
return {
"onnxruntime": package_version("onnxruntime"),
"onnx_asr": package_version("onnx-asr"),
"available_providers": ", ".join(onnxruntime.get_available_providers()),
"asr_providers": configured,
"vad_providers": configured,
"quantization": self._quantization or "float32",
"intra_op_threads": str(self._cpu_threads) if self._cpu_threads else "по умолчанию",
}
def transcribe( def transcribe(
self, self,
model: Any, model: Any,
@@ -278,7 +313,7 @@ class OnnxAsrBackend:
fallback = _whisper_fallback_model(model_name) fallback = _whisper_fallback_model(model_name)
raise ValueError( raise ValueError(
f"Модель '{model_name}' относится к Whisper и не поддерживается " f"Модель '{model_name}' относится к Whisper и не поддерживается "
"ONNX-бэкендом. Без CUDA --device auto выбирает ONNX; " "ONNX-бэкендом. --device auto всегда выбирает ONNX CPU; "
f"укажите --device openvino-cpu --model {model_name} на x86, " f"укажите --device openvino-cpu --model {model_name} на x86, "
f"--device cpu --model {fallback} на любой платформе " f"--device cpu --model {fallback} на любой платформе "
f"или --device cuda --model {fallback} при NVIDIA GPU." 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: def _format_compute_types(quantizations: frozenset[str | None]) -> str:
values = [_compute_type_for_quantization(value) for value in quantizations] values = [_compute_type_for_quantization(value) for value in quantizations]
return ", ".join(sorted(values)) return ", ".join(sorted(values))
@@ -19,6 +19,7 @@ from local_transcriber.types import (
Word, Word,
WordTimestampsUnavailableError, WordTimestampsUnavailableError,
) )
from local_transcriber.utils import package_version
# (model_alias, compute_type) → HF repo # (model_alias, compute_type) → HF repo
MODEL_REPOS: dict[tuple[str, str], str] = { MODEL_REPOS: dict[tuple[str, str], str] = {
@@ -56,6 +57,7 @@ MODEL_REQUIRED_FILES = [
class OpenVINOBackend: class OpenVINOBackend:
"""Бэкенд транскрипции через openvino-genai WhisperPipeline.""" """Бэкенд транскрипции через openvino-genai WhisperPipeline."""
engine = "openvino"
word_timestamps_available = True word_timestamps_available = True
def __init__( def __init__(
@@ -203,6 +205,18 @@ class OpenVINOBackend:
words=words, words=words,
) )
def runtime_info(self) -> dict[str, str]:
"""Версия OpenVINO, доступные устройства и выбранное фактически."""
from openvino import Core
return {
"openvino": package_version("openvino"),
"available_devices": ", ".join(Core().available_devices),
"device": self.actual_ov_device or "",
"compute_type": self.actual_compute_type or "",
"threads": "управляет OpenVINO, --threads не применяется",
}
def _resolve_repo(self, model_name: str, compute_type: str) -> tuple[str, str]: def _resolve_repo(self, model_name: str, compute_type: str) -> tuple[str, str]:
"""Находит HF repo для пары (model, compute_type) с fallback. """Находит HF repo для пары (model, compute_type) с fallback.
+88 -189
View File
@@ -13,8 +13,6 @@ from rich.status import Status
from .config import ( from .config import (
CliValues, CliValues,
ResolvedConfig,
apply_device_defaults,
load_config, load_config,
resolve_defaults, resolve_defaults,
) )
@@ -39,11 +37,12 @@ from .quality import (
) )
from .speaker_diarizer import SpeakerDiarizer, load_speaker_diarizer from .speaker_diarizer import SpeakerDiarizer, load_speaker_diarizer
from .transcriber import ( from .transcriber import (
ExecutionInfo,
ExecutionRequest,
Segment, Segment,
Transcriber,
TranscribeResult, TranscribeResult,
_is_cuda_error, cuda_error_hint,
_transcribe_file,
load_model,
) )
from .types import ( from .types import (
UNKNOWN_LANGUAGE, UNKNOWN_LANGUAGE,
@@ -53,10 +52,7 @@ from .types import (
) )
from .utils import ( from .utils import (
build_output_path, build_output_path,
detect_device,
expand_globs, expand_globs,
get_gpu_name,
get_intel_gpu_name,
has_existing_transcript, has_existing_transcript,
validate_input_file, validate_input_file,
) )
@@ -65,6 +61,13 @@ app = typer.Typer()
console = Console(stderr=True) 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( def _show_cli_warning(
message: Warning | str, message: Warning | str,
*_args: object, *_args: object,
@@ -88,21 +91,6 @@ def _with_cli_warning_renderer(
return wrapped return wrapped
def _format_device_info(device_used: str) -> str:
"""Формирует строку устройства для шапки транскрипта."""
if device_used == "cuda":
gpu_name = get_gpu_name()
return f"CUDA ({gpu_name or 'Unknown GPU'})"
if device_used == "openvino-gpu":
gpu_name = get_intel_gpu_name()
return f"OpenVINO ({gpu_name or 'Intel GPU'})"
if device_used in ("openvino", "openvino-cpu"):
return "OpenVINO (CPU)"
if device_used == "onnx":
return "ONNX (CPU)"
return "CPU"
def _format_language_mode(requested_language: str, result: TranscribeResult) -> str: def _format_language_mode(requested_language: str, result: TranscribeResult) -> str:
"""Описывает источник языка, не выдавая профиль модели за детектор.""" """Описывает источник языка, не выдавая профиль модели за детектор."""
if requested_language != "auto": if requested_language != "auto":
@@ -341,6 +329,7 @@ def main(
) )
raise SystemExit(2) raise SystemExit(2)
requested_device: str | None = None
try: try:
config = load_config() config = load_config()
cli_values: CliValues = { cli_values: CliValues = {
@@ -352,11 +341,17 @@ def main(
} }
defaults = resolve_defaults(cli_values, config) defaults = resolve_defaults(cli_values, config)
diarize_enabled = defaults["diarize"] is True or speakers is not None diarize_enabled = defaults["diarize"] is True or speakers is not None
requested_device = defaults["device"]
resolved_device = detect_device(defaults["device"]) # Умолчания model/compute_type зависят от устройства — их разрешает module.
defaults = apply_device_defaults(defaults, resolved_device, cli_values, config) request = ExecutionRequest(
device=requested_device,
ct_explicit = compute_type is not None or "compute_type" in config model=defaults["model"],
compute_type=defaults["compute_type"],
language=defaults["language"],
cpu_threads=threads,
require_word_timestamps=diarize_enabled,
)
expanded = expand_globs(files) expanded = expand_globs(files)
if not expanded: if not expanded:
@@ -373,22 +368,18 @@ def main(
if is_batch: if is_batch:
_run_batch( _run_batch(
expanded, expanded,
defaults, request,
verbose, verbose,
force, force,
ct_explicit,
cpu_threads=threads,
diarize=diarize_enabled, diarize=diarize_enabled,
speakers=speakers, speakers=speakers,
) )
else: else:
_run_single( _run_single(
expanded[0], expanded[0],
defaults, request,
output, output,
verbose, verbose,
ct_explicit,
cpu_threads=threads,
diarize=diarize_enabled, diarize=diarize_enabled,
speakers=speakers, speakers=speakers,
) )
@@ -398,21 +389,15 @@ def main(
except SystemExit: except SystemExit:
raise raise
except ValueError as exc: except ValueError as exc:
_print_cuda_hint(exc, requested_device)
console.print(f"Ошибка: {exc}", style="red bold") console.print(f"Ошибка: {exc}", style="red bold")
raise SystemExit(1) raise SystemExit(1)
except (FileNotFoundError,) as exc: except (FileNotFoundError,) as exc:
_print_cuda_hint(exc, requested_device)
console.print(f"Ошибка: {exc}", style="red bold") console.print(f"Ошибка: {exc}", style="red bold")
raise SystemExit(1) raise SystemExit(1)
except Exception as exc: except Exception as exc:
if _is_cuda_error(exc) and sys.platform == "win32": _print_cuda_hint(exc, requested_device)
console.print(
"GPU на Windows требует CUDA toolkit (включает cuBLAS).\n"
"Установите одним из способов:\n"
" choco install cuda\n"
" winget install -e --id Nvidia.CUDA\n"
"После установки перезапустите терминал.",
style="yellow",
)
if verbose: if verbose:
console.print_exception() console.print_exception()
else: else:
@@ -421,13 +406,50 @@ def main(
raise SystemExit(1) raise SystemExit(1)
def _print_execution_header(info: ExecutionInfo, verbose: bool) -> None:
"""Печатает выбранное исполнение; в --verbose — версии runtime для диагностики."""
console.print(
f"Модель: [bold]{info.model}[/bold] "
f"Устройство: [bold]{info.device}[/bold] "
f"Compute: [bold]{info.compute_type}[/bold]"
)
if info.device == "openvino-gpu" and info.model != "large-v3":
console.print(
"Совет: --model large-v3 даёт лучшее качество на GPU (~2x дольше)",
style="dim",
)
if info.device != info.resolved_device:
console.print(
f"Запрошено {info.requested_device}, используется {info.device}",
style="yellow",
)
if verbose:
threads = info.cpu_threads or "по умолчанию библиотеки"
console.print(
f"Движок: {info.engine} Потоки (запрошено): {threads}", style="dim"
)
for key, value in info.runtime.items():
console.print(f" {key}: {value}", style="dim", markup=False)
def _load_diarizer(
diarize: bool, speakers: int | None, cpu_threads: int
) -> SpeakerDiarizer | None:
"""Готовит диаризатор до первого ASR; пословный контракт уже проверен module."""
if not diarize:
return None
return load_speaker_diarizer(
speakers=speakers,
threads=cpu_threads,
on_status=lambda message: console.print(message),
)
def _run_single( def _run_single(
file: Path, file: Path,
defaults: ResolvedConfig, request: ExecutionRequest,
output: Path | None, output: Path | None,
verbose: bool, verbose: bool,
compute_type_explicit: bool = False,
cpu_threads: int = 0,
diarize: bool = False, diarize: bool = False,
speakers: int | None = None, speakers: int | None = None,
) -> None: ) -> None:
@@ -435,9 +457,6 @@ def _run_single(
start = time.monotonic() start = time.monotonic()
validated_file = validate_input_file(file) validated_file = validate_input_file(file)
requested_device = defaults["device"]
resolved_device = detect_device(requested_device)
strict = requested_device != "auto"
output_path = build_output_path(validated_file, output) output_path = build_output_path(validated_file, output)
console.print(f"Файл: [bold]{validated_file.name}[/bold]") console.print(f"Файл: [bold]{validated_file.name}[/bold]")
@@ -445,59 +464,18 @@ def _run_single(
def on_segment(seg: Segment) -> None: def on_segment(seg: Segment) -> None:
console.print(f" [{seg.start:.2f}s] {seg.text.strip()}") console.print(f" [{seg.start:.2f}s] {seg.text.strip()}")
model_obj, actual_device, backend, model_path = load_model( transcriber = Transcriber(request)
defaults["model"], info = transcriber.prepare(on_status=lambda msg: console.print(msg))
resolved_device, _print_execution_header(info, verbose)
defaults["compute_type"], speaker_diarizer = _load_diarizer(diarize, speakers, request.cpu_threads)
on_status=lambda msg: console.print(msg),
strict_device=strict,
compute_type_explicit=compute_type_explicit,
cpu_threads=cpu_threads,
)
actual_ct = (
getattr(backend, "actual_compute_type", defaults["compute_type"])
or defaults["compute_type"]
)
console.print(
f"Модель: [bold]{defaults['model']}[/bold] "
f"Устройство: [bold]{actual_device}[/bold] "
f"Compute: [bold]{actual_ct}[/bold]"
)
if actual_device == "openvino-gpu" and defaults["model"] != "large-v3":
console.print(
"Совет: --model large-v3 даёт лучшее качество на GPU (~2x дольше)",
style="dim",
)
speaker_diarizer = None
if diarize:
if not backend.word_timestamps_available:
raise ValueError(
"Выбранный движок или модель не поддерживает пословные таймкоды"
)
speaker_diarizer = load_speaker_diarizer(
speakers=speakers,
threads=cpu_threads,
on_status=lambda message: console.print(message),
)
with Status("Подготавливаю запуск...", console=console) as status: with Status("Подготавливаю запуск...", console=console) as status:
tfr = _transcribe_file( result = transcriber.transcribe(
model=model_obj, validated_file,
actual_device=actual_device,
backend=backend,
model_path=model_path,
file_path=validated_file,
model_name=defaults["model"],
compute_type=defaults["compute_type"],
language=defaults["language"] if defaults["language"] != "auto" else None,
on_segment=on_segment if verbose else None, on_segment=on_segment if verbose else None,
on_status=status.update, on_status=status.update,
strict_device=strict,
cpu_threads=cpu_threads,
) )
result = tfr.result
speaker_transcript = None speaker_transcript = None
diarization_warning = None diarization_warning = None
diarization_degraded = False diarization_degraded = False
@@ -524,33 +502,19 @@ def _run_single(
if diarization_warning is not None: if diarization_warning is not None:
console.print(f"Внимание: {diarization_warning}", style="yellow") console.print(f"Внимание: {diarization_warning}", style="yellow")
if tfr.actual_device != resolved_device:
if requested_device == "auto":
console.print(
f"Определено устройство {resolved_device}, "
f"но использовано {tfr.actual_device} (fallback)",
style="yellow",
)
else:
console.print(
f"Запрошено {requested_device}, использовано {tfr.actual_device}",
style="yellow",
)
if len(result.segments) == 0: if len(result.segments) == 0:
message = f"Речь не обнаружена в файле {validated_file.name}" message = f"Речь не обнаружена в файле {validated_file.name}"
if speaker_diarizer is not None: if speaker_diarizer is not None:
message += "; диаризация не запускалась" message += "; диаризация не запускалась"
console.print(message, style="yellow") console.print(message, style="yellow")
device_info = _format_device_info(result.device_used) language_mode = _format_language_mode(request.language or "auto", result)
language_mode = _format_language_mode(defaults["language"], result)
content = format_transcript( content = format_transcript(
result=result, result=result,
source_filename=validated_file.name, source_filename=validated_file.name,
model_name=defaults["model"], model_name=info.model,
device_info=device_info, device_info=info.description,
language_mode=language_mode, language_mode=language_mode,
speaker_transcript=speaker_transcript, speaker_transcript=speaker_transcript,
diarization_warning=diarization_warning, diarization_warning=diarization_warning,
@@ -567,11 +531,9 @@ def _run_single(
def _run_batch( def _run_batch(
files: list[Path], files: list[Path],
defaults: ResolvedConfig, request: ExecutionRequest,
verbose: bool, verbose: bool,
force: bool, force: bool,
compute_type_explicit: bool = False,
cpu_threads: int = 0,
diarize: bool = False, diarize: bool = False,
speakers: int | None = None, speakers: int | None = None,
) -> None: ) -> None:
@@ -601,50 +563,11 @@ def _run_batch(
raise SystemExit(1) raise SystemExit(1)
return return
# Phase 2: Load model (ensure + create в одном вызове) # Phase 2: module выполнения создаётся, когда есть работа
requested_device = defaults["device"] transcriber = Transcriber(request)
resolved_device = detect_device(requested_device) info = transcriber.prepare(on_status=lambda msg: console.print(msg))
strict = requested_device != "auto" _print_execution_header(info, verbose)
model_obj, actual_device, backend, model_path = load_model( speaker_diarizer = _load_diarizer(diarize, speakers, request.cpu_threads)
defaults["model"],
resolved_device,
defaults["compute_type"],
on_status=lambda msg: console.print(msg),
strict_device=strict,
compute_type_explicit=compute_type_explicit,
cpu_threads=cpu_threads,
)
if actual_device == "openvino-gpu" and defaults["model"] != "large-v3":
console.print(
"Совет: --model large-v3 даёт лучшее качество на GPU (~2x дольше)",
style="dim",
)
if actual_device != resolved_device:
if requested_device == "auto":
console.print(
f"Определено устройство {resolved_device}, "
f"но используется {actual_device} (fallback)",
style="yellow",
)
else:
console.print(
f"Запрошено {requested_device}, используется {actual_device}",
style="yellow",
)
speaker_diarizer = None
if diarize:
if not backend.word_timestamps_available:
raise ValueError(
"Выбранный движок или модель не поддерживает пословные таймкоды"
)
speaker_diarizer = load_speaker_diarizer(
speakers=speakers,
threads=cpu_threads,
on_status=lambda message: console.print(message),
)
# Phase 3: Transcribe # Phase 3: Transcribe
processed = 0 processed = 0
@@ -662,38 +585,15 @@ def _run_batch(
console.print(f" [{seg.start:.2f}s] {seg.text.strip()}") console.print(f" [{seg.start:.2f}s] {seg.text.strip()}")
with Status(f"{prefix}...", console=console) as status: with Status(f"{prefix}...", console=console) as status:
tfr = _transcribe_file( result = transcriber.transcribe(
model=model_obj, file,
actual_device=actual_device,
backend=backend,
model_path=model_path,
file_path=file,
model_name=defaults["model"],
compute_type=defaults["compute_type"],
language=defaults["language"]
if defaults["language"] != "auto"
else None,
on_segment=on_segment if verbose else None, on_segment=on_segment if verbose else None,
on_status=status.update on_status=status.update
if not verbose if not verbose
else lambda msg: console.print(msg), else lambda msg: console.print(msg),
strict_device=strict,
cpu_threads=cpu_threads,
) )
if tfr.actual_device != actual_device: language_mode = _format_language_mode(request.language or "auto", result)
console.print(
f" {file.name}: fallback на {tfr.actual_device} при транскрипции",
style="yellow",
)
# Обновляем после возможного mid-stream fallback
model_obj = tfr.model
actual_device = tfr.actual_device
backend = tfr.backend
model_path = tfr.model_path
result = tfr.result
language_mode = _format_language_mode(defaults["language"], result)
speaker_transcript = None speaker_transcript = None
diarization_warning = None diarization_warning = None
file_degraded = False file_degraded = False
@@ -733,13 +633,11 @@ def _run_batch(
message += "; диаризация не запускалась" message += "; диаризация не запускалась"
console.print(message, style="yellow") console.print(message, style="yellow")
device_info = _format_device_info(result.device_used)
content = format_transcript( content = format_transcript(
result=result, result=result,
source_filename=file.name, source_filename=file.name,
model_name=defaults["model"], model_name=info.model,
device_info=device_info, device_info=info.description,
language_mode=language_mode, language_mode=language_mode,
speaker_transcript=speaker_transcript, speaker_transcript=speaker_transcript,
diarization_warning=diarization_warning, diarization_warning=diarization_warning,
@@ -758,6 +656,7 @@ def _run_batch(
except KeyboardInterrupt: except KeyboardInterrupt:
raise raise
except Exception as exc: except Exception as exc:
_print_cuda_hint(exc, request.device)
if verbose: if verbose:
console.print_exception() console.print_exception()
else: else:
+15 -30
View File
@@ -30,12 +30,16 @@ class CliValues(TypedDict, total=False):
class ResolvedConfig(TypedDict): class ResolvedConfig(TypedDict):
"""Полная конфигурация после разрешения каскада.""" """Конфигурация после каскада CLI > TOML.
model: str ``model`` и ``compute_type`` остаются ``None``, если не заданы явно:
их умолчания зависят от устройства и разрешаются при загрузке модели.
"""
model: str | None
language: str language: str
device: str device: str
compute_type: str compute_type: str | None
diarize: bool diarize: bool
@@ -47,6 +51,8 @@ HARDCODED_DEFAULTS: ResolvedConfig = {
"diarize": False, "diarize": False,
} }
# Умолчания по устройству; HARDCODED_DEFAULTS — последний резерв для
# устройств вне таблицы.
DEVICE_DEFAULTS: dict[str, dict[str, str]] = { DEVICE_DEFAULTS: dict[str, dict[str, str]] = {
"cuda": {"model": "medium", "compute_type": "float16"}, "cuda": {"model": "medium", "compute_type": "float16"},
"cpu": {"model": "medium", "compute_type": "float32"}, "cpu": {"model": "medium", "compute_type": "float32"},
@@ -136,11 +142,12 @@ def load_config(path: Path | None = None) -> ConfigValues:
def resolve_defaults(cli_values: CliValues, config: ConfigValues) -> ResolvedConfig: def resolve_defaults(cli_values: CliValues, config: ConfigValues) -> ResolvedConfig:
"""Каскад приоритетов: CLI > конфиг-файл > hardcoded-дефолты.""" """Каскад приоритетов: CLI > конфиг-файл > hardcoded-дефолты.
Device-aware ключи (model, compute_type) без явного значения остаются None.
"""
return { return {
"model": _resolve_value( "model": _resolve_value(cli_values.get("model"), config.get("model"), None),
cli_values.get("model"), config.get("model"), HARDCODED_DEFAULTS["model"]
),
"language": _resolve_value( "language": _resolve_value(
cli_values.get("language"), cli_values.get("language"),
config.get("language"), config.get("language"),
@@ -152,9 +159,7 @@ def resolve_defaults(cli_values: CliValues, config: ConfigValues) -> ResolvedCon
HARDCODED_DEFAULTS["device"], HARDCODED_DEFAULTS["device"],
), ),
"compute_type": _resolve_value( "compute_type": _resolve_value(
cli_values.get("compute_type"), cli_values.get("compute_type"), config.get("compute_type"), None
config.get("compute_type"),
HARDCODED_DEFAULTS["compute_type"],
), ),
"diarize": _resolve_value( "diarize": _resolve_value(
cli_values.get("diarize"), cli_values.get("diarize"),
@@ -171,23 +176,3 @@ def _resolve_value(cli_value: _T | None, config_value: _T | None, default: _T) -
if config_value is not None: if config_value is not None:
return config_value return config_value
return default return default
def apply_device_defaults(
defaults: ResolvedConfig,
resolved_device: str,
cli_values: CliValues,
config: ConfigValues,
) -> ResolvedConfig:
"""Применяет device-aware дефолты для model и compute_type,
если они не были явно заданы через CLI или конфиг."""
device_defs = DEVICE_DEFAULTS.get(resolved_device, {})
if not device_defs:
return defaults
result = dict(defaults)
for key in ("model", "compute_type"):
if cli_values.get(key) is None and key not in config:
if key in device_defs:
result[key] = device_defs[key]
return cast(ResolvedConfig, result)
+239 -117
View File
@@ -2,170 +2,245 @@
import warnings import warnings
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from local_transcriber.backends import get_backend from local_transcriber.backends import get_backend
from local_transcriber.config import DEVICE_DEFAULTS, HARDCODED_DEFAULTS
# Re-export из types.py для обратной совместимости # Re-export из types.py для обратной совместимости
from local_transcriber.types import ( # noqa: F401 from local_transcriber.types import (
Segment, Segment,
TranscribeFileResult,
TranscribeResult, TranscribeResult,
WordTimestampsUnavailableError, WordTimestampsUnavailableError,
) )
from local_transcriber.utils import detect_device, get_gpu_name, get_intel_gpu_name
def load_model( @dataclass(frozen=True)
model_name: str, class ExecutionRequest:
device: str, """Запрошенные настройки запуска до разрешения умолчаний.
compute_type: str,
on_status: Callable[[str], None] | None = None,
strict_device: bool = False,
compute_type_explicit: bool = False,
cpu_threads: int = 0,
) -> tuple[Any, str, Any, str]:
"""Загружает модель: ensure + create с fallback.
Возвращает (model, actual_device, backend, model_path). ``None`` в model/compute_type означает device-aware умолчание;
compute_type_explicit: True если пользователь явно указал --compute-type. ``strict_device=None`` — strict для любого явного device, кроме ``auto``.
cpu_threads: число потоков для CPU inference (0 = дефолт библиотеки).
""" """
backend = get_backend(device, compute_type_explicit=compute_type_explicit)
actual_device = device
model_path = backend.ensure_model_available(model_name, compute_type, on_status) device: str = "auto"
model: str | None = None
compute_type: str | None = None
language: str | None = None
cpu_threads: int = 0
strict_device: bool | None = None
require_word_timestamps: bool = False
try:
_notify_status(on_status, f"Инициализирую модель на {device}...") @dataclass(frozen=True)
model = backend.create_model( class ExecutionInfo:
model_path, device, compute_type, cpu_threads=cpu_threads """Сведения о выполнении: что запрошено, что выбрано и чем выполняется."""
requested_device: str
resolved_device: str # выбор до загрузки; отличается от device после fallback
device: str
engine: str
model: str
compute_type: str
cpu_threads: int
word_timestamps_available: bool
description: str # строка исполнения для шапки транскрипта
runtime: dict[str, str] = field(default_factory=dict)
class Transcriber:
"""Module выполнения распознавания на один запуск.
Владеет загруженной моделью, adapter'ом, фактическими настройками
и разрешёнными переходами исполнения. Caller передаёт файлы по одному
и получает результат; состояние между файлами не переносит.
"""
def __init__(self, request: ExecutionRequest) -> None:
self._request = request
self._strict = (
request.strict_device
if request.strict_device is not None
else request.device != "auto"
) )
# Резолвим actual_device по реальному OpenVINO device self._compute_type_explicit = request.compute_type is not None
ov_dev = getattr(backend, "actual_ov_device", None) self._resolved_device = detect_device(request.device)
if ov_dev == "GPU" and actual_device != "openvino-gpu": self._device = self._resolved_device
actual_device = "openvino-gpu" self._backend: Any = None
elif ( self._model: Any = None
ov_dev == "CPU" self._info: ExecutionInfo | None = None
and actual_device.startswith("openvino")
and actual_device != "openvino-cpu" @property
): def execution(self) -> ExecutionInfo:
actual_device = "openvino-cpu" """Текущие сведения о выполнении; доступны после prepare()."""
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: except (RuntimeError, ValueError) as exc:
if device != "cpu" and _is_backend_error(exc, device): if not self._may_fall_back(exc):
if strict_device:
raise raise
warnings.warn( warnings.warn(
f"Не удалось загрузить модель на {device}: {exc}. Переключение на CPU.", f"Не удалось загрузить модель на {self._device}: {exc}. "
"Переключение на CPU.",
stacklevel=2, stacklevel=2,
) )
actual_device = "cpu" info = self._load("cpu", on_status)
backend = get_backend("cpu") if self._request.require_word_timestamps and not info.word_timestamps_available:
model_path = backend.ensure_model_available( raise ValueError(
model_name, compute_type, on_status "Выбранный движок или модель не поддерживает пословные таймкоды"
) )
_notify_status(on_status, "Инициализирую модель на cpu...") self._info = info
model = backend.create_model( return info
model_path, "cpu", compute_type, cpu_threads=cpu_threads
)
else:
raise
return model, actual_device, backend, model_path def transcribe(
self,
def _transcribe_file(
model: Any,
actual_device: str,
backend: Any,
model_path: str,
file_path: Path, file_path: Path,
model_name: str,
compute_type: str,
language: str | None = None,
on_segment: Callable[[Segment], None] | None = None, on_segment: Callable[[Segment], None] | None = None,
on_status: Callable[[str], None] | None = None, on_status: Callable[[str], None] | None = None,
strict_device: bool = False, ) -> TranscribeResult:
cpu_threads: int = 0, """Транскрибирует один файл; при разрешённом fallback перезагружает модель."""
) -> TranscribeFileResult: self.prepare(on_status)
"""Транскрибирует один файл. При mid-stream fallback перезагружает модель.""" lang_arg = self._request.language or None
lang_arg = language if language and language != "auto" else None if lang_arg == "auto":
lang_arg = None
try: try:
_notify_status(on_status, "Транскрибирую...") result = self._recognize(file_path, lang_arg, on_segment, on_status)
result = backend.transcribe(model, file_path, lang_arg, on_segment, on_status)
result.device_used = actual_device
except (RuntimeError, ValueError) as exc: except (RuntimeError, ValueError) as exc:
if actual_device != "cpu" and _is_backend_error(exc, actual_device): if not self._may_fall_back(exc):
if strict_device:
raise raise
warnings.warn( warnings.warn(
f"Ошибка при транскрипции на {actual_device}: {exc}. " f"Ошибка при транскрипции на {self._device}: {exc}. "
"Переключение на CPU и повтор.", "Переключение на CPU и повтор.",
stacklevel=2, stacklevel=2,
) )
actual_device = "cpu" self._info = self._load("cpu", on_status)
backend = get_backend("cpu") result = self._recognize(file_path, lang_arg, on_segment, on_status)
model_path = backend.ensure_model_available( return result
model_name, compute_type, on_status
)
_notify_status(on_status, "Инициализирую модель на cpu...")
model = backend.create_model(
model_path, "cpu", compute_type, cpu_threads=cpu_threads
)
_notify_status(on_status, "Транскрибирую...")
result = backend.transcribe(
model, file_path, lang_arg, on_segment, on_status
)
result.device_used = actual_device
else:
raise
return TranscribeFileResult( def _recognize(
result=result, self,
model=model, file_path: Path,
actual_device=actual_device, language: str | None,
backend=backend, on_segment: Callable[[Segment], None] | None,
model_path=model_path, on_status: Callable[[str], None] | None,
) -> TranscribeResult:
_notify_status(on_status, "Транскрибирую...")
result = self._backend.transcribe(
self._model, file_path, language, on_segment, on_status
) )
result.device_used = self._device
return result
def _may_fall_back(self, exc: BaseException) -> bool:
"""Разрешённый переход: не strict, не CPU и ошибка самого движка."""
if self._strict or self._device == "cpu":
return False
return _is_backend_error(exc, self._device)
def _load(
self, device: str, on_status: Callable[[str], None] | None
) -> ExecutionInfo:
"""Загружает модель на device; неявные model/compute_type берутся из его умолчаний."""
defaults = DEVICE_DEFAULTS.get(device, {})
model_name = self._request.model or defaults.get(
"model", HARDCODED_DEFAULTS["model"]
)
compute_type = self._request.compute_type or defaults.get(
"compute_type", HARDCODED_DEFAULTS["compute_type"]
)
backend = get_backend(device, compute_type_explicit=self._compute_type_explicit)
model_path = backend.ensure_model_available(model_name, compute_type, on_status)
_notify_status(on_status, f"Инициализирую модель на {device}...")
model = backend.create_model(
model_path,
device,
compute_type,
cpu_threads=self._request.cpu_threads,
)
self._backend = backend
self._model = model
self._device = _refine_openvino_device(device, backend)
return ExecutionInfo(
requested_device=self._request.device,
resolved_device=self._resolved_device,
device=self._device,
engine=backend.engine,
model=model_name,
compute_type=getattr(backend, "actual_compute_type", None) or compute_type,
cpu_threads=self._request.cpu_threads,
word_timestamps_available=bool(backend.word_timestamps_available),
description=_describe_device(self._device),
runtime=_collect_runtime_info(backend),
)
def _collect_runtime_info(backend: Any) -> dict[str, str]:
"""Диагностика не должна ронять запуск: сбой попадает в сведения строкой."""
try:
return dict(backend.runtime_info())
except Exception as exc: # noqa: BLE001
return {"diagnostics": f"недоступно ({exc})"}
def _refine_openvino_device(device: str, backend: Any) -> str:
"""Заменяет запрошенный openvino-* на устройство, которое реально выбрал OpenVINO."""
ov_dev = getattr(backend, "actual_ov_device", None)
if ov_dev == "GPU" and device != "openvino-gpu":
return "openvino-gpu"
if ov_dev == "CPU" and device.startswith("openvino") and device != "openvino-cpu":
return "openvino-cpu"
return device
def _describe_device(device: str) -> str:
"""Строка исполнения для шапки транскрипта; GPU называется только по данным драйвера."""
if device == "cuda":
return f"CUDA ({get_gpu_name() or 'Unknown GPU'})"
if device == "openvino-gpu":
return f"OpenVINO ({get_intel_gpu_name() or 'Intel GPU'})"
if device == "openvino-cpu":
return "OpenVINO (CPU)"
if device == "onnx":
return "ONNX (CPU)"
return "CPU"
def transcribe( def transcribe(
file_path: Path, file_path: Path,
model_name: str = "large-v3", model_name: str | None = None,
device: str = "auto", device: str = "auto",
compute_type: str = "int8", compute_type: str | None = None,
language: str | None = None, language: str | None = None,
on_segment: Callable[[Segment], None] | None = None, on_segment: Callable[[Segment], None] | None = None,
on_status: Callable[[str], None] | None = None, on_status: Callable[[str], None] | None = None,
strict_device: bool = False, strict_device: bool = False,
cpu_threads: int = 0, cpu_threads: int = 0,
) -> TranscribeResult: ) -> TranscribeResult:
"""High-level API: загрузка модели + транскрипция за один вызов.""" """Публичный путь одного файла через тот же module выполнения, что и CLI.
model, actual_device, backend, model_path = load_model(
model_name, ``None`` в model_name/compute_type берёт умолчания выбранного исполнения.
device, """
compute_type, run = Transcriber(
on_status, ExecutionRequest(
strict_device, device=device,
compute_type_explicit=True, # Python API — caller explicitly chose compute_type model=model_name,
compute_type=compute_type,
language=language,
cpu_threads=cpu_threads, 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( def ensure_model_available(
@@ -175,8 +250,6 @@ def ensure_model_available(
on_status: Callable[[str], None] | None = None, on_status: Callable[[str], None] | None = None,
) -> str: ) -> str:
"""Публичный helper: гарантирует наличие модели для указанного бэкенда.""" """Публичный helper: гарантирует наличие модели для указанного бэкенда."""
from local_transcriber.config import DEVICE_DEFAULTS, HARDCODED_DEFAULTS
if compute_type is None: if compute_type is None:
device_defs = DEVICE_DEFAULTS.get(device, {}) device_defs = DEVICE_DEFAULTS.get(device, {})
compute_type = device_defs.get( compute_type = device_defs.get(
@@ -190,11 +263,60 @@ def ensure_model_available(
def _is_cuda_error(exc: BaseException) -> bool: def _is_cuda_error(exc: BaseException) -> bool:
"""Проверка CUDA ошибок — используется в cli.py для Windows-диагностики.""" """Проверяет, относится ли ошибка к CUDA-бэкенду."""
msg = str(exc).lower() msg = str(exc).lower()
return any(k in msg for k in ("cuda", "cublas", "cudnn", "out of memory")) 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: def _is_backend_error(exc: BaseException, device: str) -> bool:
"""Определяет, связана ли ошибка с конкретным бэкендом (а не с пользовательскими данными).""" """Определяет, связана ли ошибка с конкретным бэкендом (а не с пользовательскими данными)."""
if isinstance(exc, WordTimestampsUnavailableError): if isinstance(exc, WordTimestampsUnavailableError):
-10
View File
@@ -2,7 +2,6 @@
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any
# Единый признак «язык неизвестен» для всех бэкендов # Единый признак «язык неизвестен» для всех бэкендов
UNKNOWN_LANGUAGE = "unknown" UNKNOWN_LANGUAGE = "unknown"
@@ -83,14 +82,5 @@ class TranscribeResult:
words: list[Word] = field(default_factory=list) words: list[Word] = field(default_factory=list)
@dataclass
class TranscribeFileResult:
result: TranscribeResult
model: Any # backend-specific model handle
actual_device: str
backend: Any = None # backend instance (для переиспользования в батче)
model_path: str = "" # путь к модели (меняется при cross-backend fallback)
StatusCallback = Callable[[str], None] | None StatusCallback = Callable[[str], None] | None
SegmentCallback = Callable[[Segment], None] | None SegmentCallback = Callable[[Segment], None] | None
+11 -5
View File
@@ -2,9 +2,9 @@
import glob import glob
import platform import platform
import shutil
import subprocess import subprocess
import warnings import warnings
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path from pathlib import Path
SUPPORTED_EXTENSIONS = { SUPPORTED_EXTENSIONS = {
@@ -16,15 +16,13 @@ SUPPORTED_EXTENSIONS = {
def detect_device(requested: str = "auto") -> str: def detect_device(requested: str = "auto") -> str:
"""Определяет устройство для вычислений. """Определяет устройство для вычислений.
При ``requested="auto"`` выбирает CUDA при наличии ``nvidia-smi``, При ``requested="auto"`` всегда выбирает ONNX на CPU.
иначе ONNX на CPU. CUDA включается только явным выбором пользователя.
``"openvino"`` — алиас для авто-детекта внутри OpenVINO ``"openvino"`` — алиас для авто-детекта внутри OpenVINO
(GPU если доступен, иначе CPU). (GPU если доступен, иначе CPU).
Возвращает всегда конкретное значение (не абстрактный ``"openvino"``). Возвращает всегда конкретное значение (не абстрактный ``"openvino"``).
""" """
if requested == "auto": if requested == "auto":
if shutil.which("nvidia-smi") is not None:
return "cuda"
return "onnx" return "onnx"
if requested == "openvino": if requested == "openvino":
if _is_openvino_gpu_available(): 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: def has_existing_transcript(input_path: Path) -> bool:
"""Проверяет наличие транскрипта для skip-логики батч-режима.""" """Проверяет наличие транскрипта для skip-логики батч-режима."""
return build_output_path(input_path).exists() return build_output_path(input_path).exists()
def package_version(name: str) -> str:
"""Версия установленного пакета для диагностики; отсутствие не считается ошибкой."""
try:
return version(name)
except PackageNotFoundError:
return "не установлен"
+34
View File
@@ -7,6 +7,25 @@ from local_transcriber.backends.faster_whisper import FasterWhisperBackend
from local_transcriber.types import Word 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): def test_transcribe_returns_canonical_words(tmp_path):
audio = tmp_path / "audio.wav" audio = tmp_path / "audio.wav"
raw_word = SimpleNamespace(start=0.2, end=0.7, word=" Привет") 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="пословные таймкоды"): with pytest.raises(RuntimeError, match="пословные таймкоды"):
FasterWhisperBackend().transcribe(model, tmp_path / "audio.wav", "ru") FasterWhisperBackend().transcribe(model, tmp_path / "audio.wav", "ru")
def test_runtime_info_reports_versions_and_cuda_devices(monkeypatch):
"""Диагностика называет версии runtime и число CUDA-устройств, не обещая ускорения."""
monkeypatch.setattr("ctranslate2.get_cuda_device_count", lambda: 0)
backend = FasterWhisperBackend()
backend.actual_compute_type = "float32"
info = backend.runtime_info()
assert info["ctranslate2"]
assert info["faster_whisper"]
assert info["cuda_devices"] == "0"
assert info["compute_type"] == "float32"
assert info["cpu_threads"] == "по умолчанию"
+18
View File
@@ -463,3 +463,21 @@ def test_validate_model_dir_requires_alignment_heads_for_word_timestamps(tmp_pat
with pytest.raises(ValueError, match="alignment_heads"): with pytest.raises(ValueError, match="alignment_heads"):
_validate_model_dir(tmp_path) _validate_model_dir(tmp_path)
def test_runtime_info_reports_available_and_actual_devices(monkeypatch):
"""Диагностика показывает устройства OpenVINO и то, что выбрано фактически."""
core = MagicMock()
core.available_devices = ["CPU", "GPU"]
monkeypatch.setattr("openvino.Core", lambda: core)
backend = OpenVINOBackend(ov_device="openvino-gpu", compute_type_explicit=True)
backend.actual_ov_device = "GPU"
backend.actual_compute_type = "fp16"
info = backend.runtime_info()
assert info["openvino"]
assert info["available_devices"] == "CPU, GPU"
assert info["device"] == "GPU"
assert info["compute_type"] == "fp16"
assert "--threads не применяется" in info["threads"]
+358 -893
View File
File diff suppressed because it is too large. Load diff
+3 -76
View File
@@ -3,7 +3,6 @@ from unittest.mock import patch
import pytest import pytest
from local_transcriber.config import ( from local_transcriber.config import (
apply_device_defaults,
find_config_file, find_config_file,
load_config, load_config,
resolve_defaults, resolve_defaults,
@@ -107,15 +106,15 @@ def test_resolve_defaults_config_wins():
assert result["model"] == "tiny" assert result["model"] == "tiny"
def test_resolve_defaults_hardcoded_fallback(): def test_resolve_defaults_leaves_device_aware_keys_empty():
result = resolve_defaults( result = resolve_defaults(
{"model": None, "language": None, "device": None, "compute_type": None}, {} {"model": None, "language": None, "device": None, "compute_type": None}, {}
) )
assert result == { assert result == {
"model": "medium", "model": None,
"language": "ru", "language": "ru",
"device": "auto", "device": "auto",
"compute_type": "float32", "compute_type": None,
"diarize": False, "diarize": False,
} }
@@ -136,54 +135,6 @@ def test_resolve_defaults_diarize_priority(cli_value, config_value, expected):
assert resolve_defaults(cli, config)["diarize"] is expected assert resolve_defaults(cli, config)["diarize"] is expected
def test_apply_device_defaults_cuda():
defaults = {"model": "medium", "language": "ru", "device": "auto", "compute_type": "float32"}
cli = {"model": None, "language": None, "device": None, "compute_type": None}
result = apply_device_defaults(defaults, "cuda", cli, {})
assert result["model"] == "medium"
assert result["compute_type"] == "float16"
def test_apply_device_defaults_cpu():
defaults = {"model": "medium", "language": "ru", "device": "auto", "compute_type": "float32"}
cli = {"model": None, "language": None, "device": None, "compute_type": None}
result = apply_device_defaults(defaults, "cpu", cli, {})
assert result["model"] == "medium"
assert result["compute_type"] == "float32"
def test_apply_device_defaults_onnx_uses_readable_model():
defaults = {
"model": "medium",
"language": "ru",
"device": "auto",
"compute_type": "float32",
}
cli = {"model": None, "language": None, "device": None, "compute_type": None}
result = apply_device_defaults(defaults, "onnx", cli, {})
assert result["model"] == "gigaam-v3-e2e-rnnt"
assert result["compute_type"] == "int8"
def test_apply_device_defaults_cli_overrides():
defaults = {"model": "large-v3", "language": "ru", "device": "auto", "compute_type": "int8"}
cli = {"model": "large-v3", "language": None, "device": None, "compute_type": "int8"}
result = apply_device_defaults(defaults, "cuda", cli, {})
assert result["model"] == "large-v3"
assert result["compute_type"] == "int8"
def test_apply_device_defaults_config_overrides():
defaults = {"model": "small", "language": "ru", "device": "auto", "compute_type": "int8"}
cli = {"model": None, "language": None, "device": None, "compute_type": None}
config = {"model": "small", "compute_type": "int8"}
result = apply_device_defaults(defaults, "cuda", cli, config)
assert result["model"] == "small"
assert result["compute_type"] == "int8"
def test_load_config_openvino_device(tmp_path): def test_load_config_openvino_device(tmp_path):
config = tmp_path / "config.toml" config = tmp_path / "config.toml"
config.write_text('device = "openvino"\n') config.write_text('device = "openvino"\n')
@@ -203,27 +154,3 @@ def test_load_config_openvino_cpu_device(tmp_path):
config.write_text('device = "openvino-cpu"\n') config.write_text('device = "openvino-cpu"\n')
result = load_config(config) result = load_config(config)
assert result == {"device": "openvino-cpu"} assert result == {"device": "openvino-cpu"}
def test_apply_device_defaults_openvino():
defaults = {"model": "medium", "language": "ru", "device": "auto", "compute_type": "float32"}
cli = {"model": None, "language": None, "device": None, "compute_type": None}
result = apply_device_defaults(defaults, "openvino", cli, {})
assert result["model"] == "medium"
assert result["compute_type"] == "int8"
def test_apply_device_defaults_openvino_gpu():
defaults = {"model": "medium", "language": "ru", "device": "auto", "compute_type": "float32"}
cli = {"model": None, "language": None, "device": None, "compute_type": None}
result = apply_device_defaults(defaults, "openvino-gpu", cli, {})
assert result["model"] == "medium"
assert result["compute_type"] == "int8"
def test_apply_device_defaults_openvino_cpu():
defaults = {"model": "medium", "language": "ru", "device": "auto", "compute_type": "float32"}
cli = {"model": None, "language": None, "device": None, "compute_type": None}
result = apply_device_defaults(defaults, "openvino-cpu", cli, {})
assert result["model"] == "medium"
assert result["compute_type"] == "int8"
+64 -3
View File
@@ -48,12 +48,73 @@ def test_ensure_cublas_loads_library(monkeypatch, tmp_path):
assert calls[0][1] == ctypes.RTLD_GLOBAL assert calls[0][1] == ctypes.RTLD_GLOBAL
def test_ensure_cublas_skips_non_linux(monkeypatch): def test_ensure_cublas_skips_macos(monkeypatch):
"""На не-Linux платформах -- no-op.""" """На macOS bootstrap не загружает CUDA."""
monkeypatch.setattr(sys, "platform", "win32") monkeypatch.setattr(sys, "platform", "darwin")
ensure_cublas_loadable() # не должно бросать исключений 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: def _nvidia_cublas_installed() -> bool:
"""Проверяет, что pip-пакет nvidia-cublas-cu12 установлен.""" """Проверяет, что pip-пакет nvidia-cublas-cu12 установлен."""
try: try:
+78 -3
View File
@@ -1,6 +1,7 @@
"""Tests for onnx-asr backend.""" """Tests for onnx-asr backend."""
import warnings import warnings
from unittest.mock import MagicMock
import pytest import pytest
@@ -64,12 +65,12 @@ class TestEnsureModelAvailable:
def with_timestamps(self): def with_timestamps(self):
return self return self
def fake_load_model(*, model, quantization): def fake_load_model(*, model, quantization, providers):
quantizations.append(quantization) quantizations.append(quantization)
return FakeAsrAdapter() return FakeAsrAdapter()
monkeypatch.setattr("onnx_asr.load_model", fake_load_model) 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) backend = OnnxAsrBackend(compute_type_explicit=False)
model_id = backend.ensure_model_available( model_id = backend.ensure_model_available(
@@ -87,6 +88,56 @@ class TestEnsureModelAvailable:
class TestCreateModel: 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): def test_wraps_vad_model_with_timestamps(self, monkeypatch):
timestamped_model = object() timestamped_model = object()
@@ -99,7 +150,7 @@ class TestCreateModel:
return FakeVadAdapter() return FakeVadAdapter()
monkeypatch.setattr("onnx_asr.load_model", lambda **kwargs: FakeAsrAdapter()) 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") model = OnnxAsrBackend().create_model("gigaam-v3-e2e-rnnt", "onnx", "int8")
@@ -127,6 +178,7 @@ class TestCreateModel:
return self return self
monkeypatch.setattr("onnx_asr.load_model", fake_load_model) monkeypatch.setattr("onnx_asr.load_model", fake_load_model)
monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kwargs: None)
backend = OnnxAsrBackend() backend = OnnxAsrBackend()
backend.actual_compute_type = "int8" backend.actual_compute_type = "int8"
@@ -637,3 +689,26 @@ class TestModelAliases:
assert "--device cpu --model medium" in message assert "--device cpu --model medium" in message
assert "--device cuda --model large-v3-turbo" not in message assert "--device cuda --model large-v3-turbo" not in message
assert "--device cpu --model large-v3-turbo" not in message assert "--device cpu --model large-v3-turbo" not in message
class TestRuntimeInfo:
def test_reports_versions_and_configured_providers(self, monkeypatch):
"""Диагностика различает доступные providers и те, что заданы сессиям ASR и VAD."""
monkeypatch.setattr(
"onnxruntime.get_available_providers",
lambda: ["CoreMLExecutionProvider", "CPUExecutionProvider"],
)
monkeypatch.setattr("onnx_asr.load_model", lambda **kwargs: MagicMock())
monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kwargs: MagicMock())
backend = OnnxAsrBackend()
backend.actual_compute_type = "int8"
backend.create_model("gigaam-v3-e2e-rnnt", "onnx", "int8")
info = backend.runtime_info()
assert info["onnxruntime"]
assert info["onnx_asr"]
assert info["available_providers"] == "CoreMLExecutionProvider, CPUExecutionProvider"
assert info["asr_providers"] == "CPUExecutionProvider"
assert info["vad_providers"] == "CPUExecutionProvider"
assert info["quantization"] == "int8"
+471 -294
View File
@@ -4,11 +4,12 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from local_transcriber.transcriber import ( from local_transcriber.transcriber import (
ExecutionRequest,
Segment, Segment,
Transcriber,
TranscribeResult, TranscribeResult,
_transcribe_file, cuda_error_hint,
ensure_model_available, ensure_model_available,
load_model,
transcribe, transcribe,
) )
from local_transcriber.types import WordTimestampsUnavailableError from local_transcriber.types import WordTimestampsUnavailableError
@@ -16,6 +17,21 @@ from local_transcriber.types import WordTimestampsUnavailableError
# === Helpers === # === 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( def _make_result(
count: int = 2, count: int = 2,
language: str = "ru", language: str = "ru",
@@ -216,7 +232,7 @@ def test_transcribe_reports_status_transitions(mock_get_backend):
on_status=statuses.append, on_status=statuses.append,
) )
# load_model reports init status, _transcribe_file reports transcribe status # Статусы подготовки и распознавания идут через один callback
assert any("Инициализирую модель" in s for s in statuses) assert any("Инициализирую модель" in s for s in statuses)
assert any("Транскрибирую" in s for s in statuses) assert any("Транскрибирую" in s for s in statuses)
@@ -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) === # === 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)) ensure_model_available(str(model_dir))
# === Cross-backend fallback (openvino → cpu) ===
@patch("local_transcriber.transcriber.get_backend")
def test_load_model_openvino_gpu_fallback_to_cpu(mock_get_backend):
"""OpenVINO GPU ошибка при init → fallback на CPU (FasterWhisper)."""
ov_backend = _make_backend(
create_model_error=RuntimeError("OpenVINO model load failed"),
)
cpu_model = MagicMock()
cpu_backend = _make_backend(model=cpu_model, model_path="/mock/cpu/model")
def backend_for_device(device, **kwargs):
return ov_backend if device == "openvino-gpu" else cpu_backend
mock_get_backend.side_effect = backend_for_device
with pytest.warns(UserWarning, match="Переключение на CPU"):
model, actual_device, backend, model_path = load_model(
"medium",
"openvino-gpu",
"fp16",
)
assert actual_device == "cpu"
assert model is cpu_model
assert backend is cpu_backend
assert model_path == "/mock/cpu/model"
@patch("local_transcriber.transcriber.get_backend")
def test_load_model_openvino_cpu_fallback_to_cpu(mock_get_backend):
"""OpenVINO CPU ошибка при init → fallback на CPU (FasterWhisper)."""
ov_backend = _make_backend(
create_model_error=RuntimeError("OpenVINO model load failed"),
)
cpu_model = MagicMock()
cpu_backend = _make_backend(model=cpu_model, model_path="/mock/cpu/model")
def backend_for_device(device, **kwargs):
return ov_backend if device == "openvino-cpu" else cpu_backend
mock_get_backend.side_effect = backend_for_device
with pytest.warns(UserWarning, match="Переключение на CPU"):
model, actual_device, backend, model_path = load_model(
"medium",
"openvino-cpu",
"int8",
)
assert actual_device == "cpu"
assert model is cpu_model
assert backend is cpu_backend
assert model_path == "/mock/cpu/model"
@patch("local_transcriber.transcriber.get_backend")
def test_transcribe_file_openvino_gpu_midstream_fallback(mock_get_backend):
"""OpenVINO GPU ошибка при транскрипции → fallback на CPU."""
ov_backend = _make_backend(
transcribe_error=RuntimeError("OpenVINO inference error"),
)
cpu_backend = _make_backend(
transcribe_result=_make_result(count=2, device_used="cpu"),
model_path="/mock/cpu/model",
)
def backend_for_device(device, **kwargs):
return ov_backend if device.startswith("openvino") else cpu_backend
mock_get_backend.side_effect = backend_for_device
with pytest.warns(UserWarning, match="Переключение на CPU"):
tfr = _transcribe_file(
model=MagicMock(),
actual_device="openvino-gpu",
backend=ov_backend,
model_path="/mock/ov/model",
file_path=Path("test.mp3"),
model_name="medium",
compute_type="fp16",
)
assert tfr.actual_device == "cpu"
assert tfr.backend is cpu_backend
assert tfr.model_path == "/mock/cpu/model"
@patch("local_transcriber.transcriber.get_backend")
def test_transcribe_file_does_not_fallback_for_missing_word_timestamps(
mock_get_backend,
):
ov_backend = _make_backend(
transcribe_error=WordTimestampsUnavailableError("нет таймкодов"),
)
with pytest.raises(WordTimestampsUnavailableError, match="нет таймкодов"):
_transcribe_file(
model=MagicMock(),
actual_device="openvino-gpu",
backend=ov_backend,
model_path="/mock/ov/model",
file_path=Path("test.mp3"),
model_name="medium",
compute_type="fp16",
)
mock_get_backend.assert_not_called()
@patch("local_transcriber.transcriber.get_backend")
def test_transcribe_file_midstream_fallback_preserves_cpu_threads(mock_get_backend):
ov_backend = _make_backend(
transcribe_error=RuntimeError("OpenVINO inference error"),
)
cpu_backend = _make_backend(
transcribe_result=_make_result(count=2, device_used="cpu"),
model_path="/mock/cpu/model",
)
def backend_for_device(device, **kwargs):
return ov_backend if device.startswith("openvino") else cpu_backend
mock_get_backend.side_effect = backend_for_device
with pytest.warns(UserWarning, match="Переключение на CPU"):
_transcribe_file(
model=MagicMock(),
actual_device="openvino-gpu",
backend=ov_backend,
model_path="/mock/ov/model",
file_path=Path("test.mp3"),
model_name="medium",
compute_type="fp16",
cpu_threads=6,
)
assert cpu_backend.create_model.call_args.kwargs["cpu_threads"] == 6
@patch("local_transcriber.transcriber.get_backend")
def test_openvino_gpu_strict_device_no_fallback(mock_get_backend):
"""strict_device=True + OpenVINO GPU ошибка → raise."""
backend = _make_backend(
create_model_error=RuntimeError("OpenVINO model load failed"),
)
mock_get_backend.return_value = backend
with pytest.raises(RuntimeError, match="OpenVINO"):
load_model("medium", "openvino-gpu", "fp16", strict_device=True)
@patch("local_transcriber.transcriber.get_backend")
def test_openvino_runtime_error_triggers_fallback(mock_get_backend):
"""Любой RuntimeError от OpenVINO бэкенда → fallback."""
ov_backend = _make_backend(
create_model_error=RuntimeError("Exception from src/inference/..."),
)
cpu_backend = _make_backend(model_path="/mock/cpu/model")
def backend_for_device(device, **kwargs):
return ov_backend if device.startswith("openvino") else cpu_backend
mock_get_backend.side_effect = backend_for_device
with pytest.warns(UserWarning, match="Переключение на CPU"):
_, actual_device, _, _ = load_model("medium", "openvino-cpu", "int8")
assert actual_device == "cpu"
@patch("local_transcriber.transcriber.get_backend")
def test_load_model_resolves_openvino_gpu_device(mock_get_backend):
"""load_model обновляет actual_device по backend.actual_ov_device."""
backend = _make_backend()
backend.actual_ov_device = "GPU"
mock_get_backend.return_value = backend
_, actual_device, _, _ = load_model("medium", "openvino-cpu", "int8")
assert actual_device == "openvino-gpu"
@patch("local_transcriber.transcriber.get_backend")
def test_load_model_resolves_openvino_cpu_device(mock_get_backend):
"""load_model обновляет actual_device по backend.actual_ov_device."""
backend = _make_backend()
backend.actual_ov_device = "CPU"
mock_get_backend.return_value = backend
_, actual_device, _, _ = load_model("medium", "openvino-gpu", "fp16")
assert actual_device == "openvino-cpu"
def test_ensure_model_available_openvino_default_compute_type(): def test_ensure_model_available_openvino_default_compute_type():
"""ensure_model_available(device='openvino-cpu') без compute_type не падает.""" """ensure_model_available(device='openvino-cpu') без compute_type не падает."""
from local_transcriber.backends.openvino import OpenVINOBackend from local_transcriber.backends.openvino import OpenVINOBackend
@@ -681,3 +406,455 @@ def test_ensure_model_available_openvino_default_compute_type():
repo, ct = backend._resolve_repo("medium", "int8") repo, ct = backend._resolve_repo("medium", "int8")
assert repo == "OpenVINO/whisper-medium-int8-ov" assert repo == "OpenVINO/whisper-medium-int8-ov"
assert ct == "int8" assert ct == "int8"
# === Transcriber: module выполнения ===
def _make_run_backend(**kwargs):
"""Fake adapter для external interface: runtime_info возвращает словарь."""
backend = _make_backend(**kwargs)
backend.runtime_info.return_value = {}
backend.engine = "fake-engine"
backend.word_timestamps_available = True
backend.actual_compute_type = None
backend.actual_ov_device = None
return backend
@patch("local_transcriber.transcriber.get_backend")
def test_run_loads_model_once_for_two_files(mock_get_backend):
"""Два файла переиспользуют одну загрузку модели."""
backend = _make_run_backend()
mock_get_backend.return_value = backend
run = Transcriber(ExecutionRequest(device="cpu", model="tiny", compute_type="int8"))
first = run.transcribe(Path("a.mp3"))
second = run.transcribe(Path("b.mp3"))
assert backend.create_model.call_count == 1
assert backend.transcribe.call_count == 2
assert first.device_used == "cpu"
assert second.device_used == "cpu"
@patch("local_transcriber.transcriber.get_backend")
def test_run_does_not_load_model_before_first_file(mock_get_backend):
"""Создание module без файлов не загружает модель."""
mock_get_backend.return_value = _make_run_backend()
Transcriber(ExecutionRequest(device="cpu", model="tiny", compute_type="int8"))
mock_get_backend.assert_not_called()
@patch("local_transcriber.transcriber.get_backend")
def test_auto_resolves_to_onnx_cpu_with_device_defaults(mock_get_backend):
"""auto → ONNX CPU и его модель/квантизация по умолчанию; сведения различают запрошенное и выбранное."""
backend = _make_run_backend()
mock_get_backend.return_value = backend
info = Transcriber(ExecutionRequest()).prepare()
mock_get_backend.assert_called_once_with("onnx", compute_type_explicit=False)
backend.ensure_model_available.assert_called_once_with(
"gigaam-v3-e2e-rnnt", "int8", None
)
assert info.requested_device == "auto"
assert info.device == "onnx"
assert info.model == "gigaam-v3-e2e-rnnt"
assert info.compute_type == "int8"
assert info.description == "ONNX (CPU)"
@patch("local_transcriber.transcriber.get_backend")
def test_run_falls_back_to_cpu_at_load_and_keeps_state_for_next_file(
mock_get_backend,
):
"""Не-strict CUDA-ошибка при загрузке → CPU; следующий файл идёт на CPU без перезагрузки."""
cuda_backend = _make_run_backend(
create_model_error=RuntimeError("CUDA out of memory")
)
cpu_backend = _make_run_backend()
mock_get_backend.side_effect = lambda device, **_: (
cuda_backend if device == "cuda" else cpu_backend
)
run = Transcriber(
ExecutionRequest(
device="cuda",
model="tiny",
compute_type="int8",
strict_device=False,
cpu_threads=3,
)
)
with pytest.warns(UserWarning, match="Переключение на CPU"):
first = run.transcribe(Path("a.mp3"))
second = run.transcribe(Path("b.mp3"))
assert first.device_used == "cpu"
assert second.device_used == "cpu"
assert cpu_backend.create_model.call_count == 1
assert cpu_backend.create_model.call_args.kwargs["cpu_threads"] == 3
assert run.execution.resolved_device == "cuda"
assert run.execution.device == "cpu"
@patch("local_transcriber.transcriber.get_backend")
def test_run_strict_device_raises_without_fallback(mock_get_backend):
"""Явный device (strict по умолчанию) не подменяется другим исполнением."""
mock_get_backend.return_value = _make_run_backend(
create_model_error=RuntimeError("CUDA driver version is insufficient")
)
with pytest.raises(RuntimeError, match="driver version"):
Transcriber(
ExecutionRequest(device="cuda", model="tiny", compute_type="int8")
).prepare()
assert mock_get_backend.call_count == 1
@patch("local_transcriber.transcriber.get_backend")
def test_run_midstream_fallback_reloads_once_and_continues_batch(mock_get_backend):
"""CUDA-ошибка во время распознавания → повтор файла на CPU; батч продолжается на CPU."""
cuda_backend = _make_run_backend(
transcribe_error=RuntimeError("CUBLAS_STATUS_ALLOC_FAILED")
)
cpu_backend = _make_run_backend()
mock_get_backend.side_effect = lambda device, **_: (
cuda_backend if device == "cuda" else cpu_backend
)
run = Transcriber(
ExecutionRequest(
device="cuda", model="tiny", compute_type="int8", strict_device=False
)
)
with pytest.warns(UserWarning, match="Переключение на CPU и повтор"):
first = run.transcribe(Path("a.mp3"))
second = run.transcribe(Path("b.mp3"))
assert first.device_used == "cpu"
assert second.device_used == "cpu"
assert cpu_backend.create_model.call_count == 1
assert cpu_backend.transcribe.call_count == 2
assert run.execution.device == "cpu"
@patch("local_transcriber.transcriber.get_backend")
def test_run_ordinary_file_error_does_not_change_state(mock_get_backend):
"""Ошибка файла (не движка) пробрасывается, модель и исполнение остаются прежними."""
backend = _make_run_backend()
backend.transcribe.side_effect = [
ValueError("Не удалось декодировать"),
_make_result(),
]
mock_get_backend.return_value = backend
run = Transcriber(
ExecutionRequest(
device="cuda", model="tiny", compute_type="int8", strict_device=False
)
)
with pytest.raises(ValueError, match="декодировать"):
run.transcribe(Path("bad.mp3"))
result = run.transcribe(Path("good.mp3"))
assert result.device_used == "cuda"
assert backend.create_model.call_count == 1
assert mock_get_backend.call_count == 1
@patch("local_transcriber.transcriber.get_backend")
def test_run_word_timestamps_error_is_not_a_fallback_reason(mock_get_backend):
"""Нарушение пословного контракта не считается ошибкой движка."""
mock_get_backend.return_value = _make_run_backend(
transcribe_error=WordTimestampsUnavailableError("нет слов")
)
run = Transcriber(
ExecutionRequest(
device="cuda", model="tiny", compute_type="int8", strict_device=False
)
)
with pytest.raises(WordTimestampsUnavailableError):
run.transcribe(Path("a.mp3"))
assert mock_get_backend.call_count == 1
@patch("local_transcriber.transcriber.get_backend")
def test_run_requires_word_timestamps_before_first_file(mock_get_backend):
"""Требование пословного контракта проверяется при подготовке, ASR не запускается."""
backend = _make_run_backend()
backend.word_timestamps_available = False
mock_get_backend.return_value = backend
run = Transcriber(
ExecutionRequest(
device="onnx", model="some/raw-model", require_word_timestamps=True
)
)
with pytest.raises(ValueError, match="пословные таймкоды"):
run.transcribe(Path("a.mp3"))
backend.transcribe.assert_not_called()
@patch("local_transcriber.transcriber.get_backend")
def test_run_reports_actual_openvino_device(mock_get_backend):
"""openvino-gpu, выбранный до загрузки, уточняется по фактическому устройству OpenVINO."""
backend = _make_run_backend()
backend.actual_ov_device = "CPU"
backend.actual_compute_type = "int8"
mock_get_backend.return_value = backend
with patch(
"local_transcriber.transcriber.detect_device", return_value="openvino-gpu"
):
info = Transcriber(
ExecutionRequest(device="openvino", model="medium")
).prepare()
assert info.requested_device == "openvino"
assert info.resolved_device == "openvino-gpu"
assert info.device == "openvino-cpu"
assert info.compute_type == "int8"
assert info.description == "OpenVINO (CPU)"
@patch("local_transcriber.transcriber.get_gpu_name", return_value="RTX 3060")
@patch("local_transcriber.transcriber.get_backend")
def test_run_exposes_runtime_and_gpu_name_from_module_data(mock_get_backend, _gpu):
"""Сведения о runtime берутся из adapter'а, имя GPU — из драйвера, потоки — из запроса."""
backend = _make_run_backend()
backend.runtime_info.return_value = {"ctranslate2": "4.5.0"}
mock_get_backend.return_value = backend
info = Transcriber(
ExecutionRequest(
device="cuda", model="tiny", compute_type="float16", cpu_threads=4
)
).prepare()
assert info.description == "CUDA (RTX 3060)"
assert info.runtime == {"ctranslate2": "4.5.0"}
assert info.cpu_threads == 4
backend.create_model.assert_called_once_with(
"/mock/model", "cuda", "float16", cpu_threads=4
)
@patch("local_transcriber.transcriber.get_backend")
def test_run_passes_auto_language_as_none(mock_get_backend):
backend = _make_run_backend()
mock_get_backend.return_value = backend
Transcriber(
ExecutionRequest(device="cpu", model="tiny", language="auto")
).transcribe(Path("a.mp3"))
assert backend.transcribe.call_args[0][2] is None
@patch("local_transcriber.transcriber.get_backend")
def test_public_transcribe_uses_the_same_execution_module(mock_get_backend):
"""transcribe() без параметров идёт тем же путём, что CLI по умолчанию: ONNX CPU."""
backend = _make_run_backend(transcribe_result=_make_result(count=1))
mock_get_backend.return_value = backend
result = transcribe(Path("a.mp3"))
mock_get_backend.assert_called_once_with("onnx", compute_type_explicit=False)
backend.ensure_model_available.assert_called_once_with(
"gigaam-v3-e2e-rnnt", "int8", None
)
assert result.device_used == "onnx"
assert len(result.segments) == 1
@pytest.mark.parametrize(
("device", "ov_device", "gpu_name", "intel_name", "expected"),
[
("cpu", None, None, None, "CPU"),
("onnx", None, "RTX 3060", None, "ONNX (CPU)"),
("cuda", None, "RTX 3060", None, "CUDA (RTX 3060)"),
("cuda", None, None, None, "CUDA (Unknown GPU)"),
("openvino-gpu", "GPU", None, "Intel Arc 140T", "OpenVINO (Intel Arc 140T)"),
("openvino-gpu", "GPU", None, None, "OpenVINO (Intel GPU)"),
("openvino-cpu", "CPU", None, None, "OpenVINO (CPU)"),
],
)
@patch("local_transcriber.transcriber.get_backend")
def test_run_description_names_hardware_only_from_driver_data(
mock_get_backend, device, ov_device, gpu_name, intel_name, expected
):
"""Строка исполнения для шапки: ONNX не выдаётся за CPU faster-whisper, GPU — по драйверу."""
backend = _make_run_backend()
backend.actual_ov_device = ov_device
mock_get_backend.return_value = backend
with (
patch("local_transcriber.transcriber.get_gpu_name", return_value=gpu_name),
patch("local_transcriber.transcriber.get_intel_gpu_name", return_value=intel_name),
):
info = Transcriber(ExecutionRequest(device=device, model="m")).prepare()
assert info.description == expected
@patch("local_transcriber.transcriber.get_backend")
def test_run_openvino_runtime_error_falls_back_when_not_strict(mock_get_backend):
"""Любой RuntimeError OpenVINO считается ошибкой движка: не-strict запуск уходит на CPU."""
ov_backend = _make_run_backend(
create_model_error=RuntimeError("Exception from src/inference/src/core.cpp")
)
cpu_backend = _make_run_backend()
mock_get_backend.side_effect = lambda device, **_: (
ov_backend if device.startswith("openvino") else cpu_backend
)
run = Transcriber(
ExecutionRequest(device="openvino-gpu", model="medium", strict_device=False)
)
with pytest.warns(UserWarning, match="Переключение на CPU"):
result = run.transcribe(Path("a.mp3"))
assert result.device_used == "cpu"
@patch("local_transcriber.transcriber.get_backend")
def test_run_openvino_strict_raises_without_fallback(mock_get_backend):
mock_get_backend.return_value = _make_run_backend(
create_model_error=RuntimeError("GPU plugin failed")
)
with pytest.raises(RuntimeError, match="GPU plugin"):
Transcriber(ExecutionRequest(device="openvino-gpu", model="medium")).prepare()
assert mock_get_backend.call_count == 1
@patch("local_transcriber.transcriber.get_backend")
def test_run_reports_openvino_gpu_chosen_for_cpu_request(mock_get_backend):
"""Фактическое устройство OpenVINO важнее запрошенного и в обратную сторону."""
backend = _make_run_backend()
backend.actual_ov_device = "GPU"
mock_get_backend.return_value = backend
info = Transcriber(ExecutionRequest(device="openvino-cpu", model="medium")).prepare()
assert info.resolved_device == "openvino-cpu"
assert info.device == "openvino-gpu"
@patch("local_transcriber.transcriber.get_backend")
def test_run_fallback_rederives_implicit_compute_type_for_cpu(mock_get_backend):
"""Неявный compute_type следует за фактическим устройством: CPU не получает float16."""
cuda_backend = _make_run_backend(create_model_error=RuntimeError("CUDA error"))
cpu_backend = _make_run_backend()
mock_get_backend.side_effect = lambda device, **_: (
cuda_backend if device == "cuda" else cpu_backend
)
run = Transcriber(ExecutionRequest(device="cuda", strict_device=False))
with pytest.warns(UserWarning, match="Переключение на CPU"):
info = run.prepare()
cuda_backend.ensure_model_available.assert_called_once_with("medium", "float16", None)
cpu_backend.ensure_model_available.assert_called_once_with("medium", "float32", None)
assert info.compute_type == "float32"
@patch("local_transcriber.transcriber.get_backend")
def test_run_fallback_keeps_explicit_compute_type(mock_get_backend):
cuda_backend = _make_run_backend(create_model_error=RuntimeError("CUDA error"))
cpu_backend = _make_run_backend()
mock_get_backend.side_effect = lambda device, **_: (
cuda_backend if device == "cuda" else cpu_backend
)
run = Transcriber(
ExecutionRequest(device="cuda", compute_type="int8", strict_device=False)
)
with pytest.warns(UserWarning):
run.prepare()
cpu_backend.ensure_model_available.assert_called_once_with("medium", "int8", None)
@patch("local_transcriber.transcriber.get_backend")
def test_run_failed_capability_check_does_not_mark_module_prepared(mock_get_backend):
"""После отказа по пословному контракту повторный вызов не запускает ASR."""
backend = _make_run_backend()
backend.word_timestamps_available = False
mock_get_backend.return_value = backend
run = Transcriber(
ExecutionRequest(device="onnx", model="some/raw-model", require_word_timestamps=True)
)
with pytest.raises(ValueError, match="пословные таймкоды"):
run.prepare()
with pytest.raises(ValueError, match="пословные таймкоды"):
run.transcribe(Path("a.mp3"))
backend.transcribe.assert_not_called()
@pytest.mark.parametrize(
("device", "model", "compute_type"),
[
("cpu", "medium", "float32"),
("cuda", "medium", "float16"),
("openvino-cpu", "medium", "int8"),
("openvino-gpu", "medium", "int8"),
("onnx", "gigaam-v3-e2e-rnnt", "int8"),
],
)
@patch("local_transcriber.transcriber.get_backend")
def test_run_applies_device_defaults_when_request_leaves_them_empty(
mock_get_backend, device, model, compute_type
):
backend = _make_run_backend()
mock_get_backend.return_value = backend
Transcriber(ExecutionRequest(device=device)).prepare()
mock_get_backend.assert_called_once_with(device, compute_type_explicit=False)
backend.ensure_model_available.assert_called_once_with(model, compute_type, None)
@patch("local_transcriber.transcriber.get_backend")
def test_run_explicit_values_override_device_defaults(mock_get_backend):
backend = _make_run_backend()
mock_get_backend.return_value = backend
Transcriber(ExecutionRequest(device="cuda", model="large-v3", compute_type="int8")).prepare()
mock_get_backend.assert_called_once_with("cuda", compute_type_explicit=True)
backend.ensure_model_available.assert_called_once_with("large-v3", "int8", None)
@patch("local_transcriber.transcriber.get_backend")
def test_run_engine_comes_from_adapter(mock_get_backend):
backend = _make_run_backend()
backend.engine = "openvino"
mock_get_backend.return_value = backend
info = Transcriber(ExecutionRequest(device="openvino-cpu", model="medium")).prepare()
assert info.engine == "openvino"
@patch("local_transcriber.transcriber.get_backend")
def test_run_diagnostics_failure_does_not_fail_the_run(mock_get_backend):
"""Сбой сбора runtime_info не роняет распознавание, а попадает в сведения."""
backend = _make_run_backend()
backend.runtime_info.side_effect = RuntimeError("plugin enumeration failed")
mock_get_backend.return_value = backend
info = Transcriber(ExecutionRequest(device="cpu", model="tiny")).prepare()
assert "plugin enumeration failed" in info.runtime["diagnostics"]
+5 -5
View File
@@ -66,17 +66,17 @@ def test_detect_device_explicit_passthrough():
assert detect_device("openvino-cpu") == "openvino-cpu" assert detect_device("openvino-cpu") == "openvino-cpu"
def test_detect_device_auto_cuda_when_nvidia_smi_available(): def test_detect_device_auto_onnx_when_nvidia_smi_available():
"""При доступном nvidia-smi auto выбирает CUDA.""" """При доступном nvidia-smi auto остаётся на ONNX CPU."""
with patch( 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(): def test_detect_device_auto_onnx_without_cuda():
"""Без CUDA auto выбирает ONNX CPU.""" """Без 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" assert detect_device("auto") == "onnx"
Generated
+10 -2
View File
@@ -288,9 +288,9 @@ name = "local-transcriber"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "ctranslate2" },
{ name = "faster-whisper" }, { name = "faster-whisper" },
{ name = "httpx" }, { name = "httpx" },
{ name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "onnx-asr", extra = ["cpu", "hub"] }, { name = "onnx-asr", extra = ["cpu", "hub"] },
{ name = "onnxruntime" }, { name = "onnxruntime" },
{ name = "openvino-genai", marker = "(platform_machine == 'AMD64' and sys_platform != 'darwin') or (platform_machine == 'x86_64' and sys_platform != 'darwin')" }, { 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" }, { 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] [package.dev-dependencies]
dev = [ dev = [
{ name = "kaldi-native-fbank" }, { name = "kaldi-native-fbank" },
@@ -311,9 +316,10 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "ctranslate2", specifier = "==4.8.1" },
{ name = "faster-whisper", specifier = ">=1.2.1,<2" }, { name = "faster-whisper", specifier = ">=1.2.1,<2" },
{ name = "httpx", specifier = ">=0.28,<1" }, { 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 = "onnx-asr", extras = ["cpu", "hub"], specifier = ">=0.12,<0.13" },
{ name = "onnxruntime", specifier = ">=1.28,<2" }, { 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" }, { 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 = "socksio", specifier = ">=1.0.0,<2" },
{ name = "typer", specifier = ">=0.24.1,<1" }, { name = "typer", specifier = ">=0.24.1,<1" },
] ]
provides-extras = ["cuda"]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
@@ -445,6 +452,7 @@ version = "12.9.1.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
wheels = [ 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/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]] [[package]]