Compare commits

..
1 Commits
Author SHA1 Message Date
Dmitriy Dementiev 2c5ed8a0f4 docs(install): указан Python 3.13 для uv tool
- Зачем:
  - uv tool install сохранял Python 3.11 старого окружения и отклонял пакет с требованием Python 3.13.
- Что:
  - в команды установки и обновления добавлен явный выбор Python 3.13.
  - уточнено, что uv сам скачивает интерпретатор, а локальный pin не управляет tool-окружением.
  - актуализировано описание Python в спецификации.
- Проверка:
  - git diff --check.
2026-09-02 12:06:28 +03:00
43 changed files with 1876 additions and 2984 deletions

No files matched your search

+1 -1
View File
@@ -7,4 +7,4 @@ dist/
.codex .codex
.qwen/ .qwen/
tmp tmp
/.scratch/ .scratch/handoffs/
-5
View File
@@ -5,11 +5,6 @@
Не часть пакета: они опираются на `sherpa-onnx`, которого нет в зависимостях Не часть пакета: они опираются на `sherpa-onnx`, которого нет в зависимостях
проекта, и живут в `.scratch/`, а не в `src/`. проекта, и живут в `.scratch/`, а не в `src/`.
Код этого каталога временно закреплен в Git: его используют несколько задач на
нескольких компьютерах, пока идет исследование диаризации. Общее правило
`/.scratch/` относится к новым локальным материалам; уже отслеживаемые файлы
этого стенда продолжают версионироваться.
Результаты первого прогона описаны в Результаты первого прогона описаны в
[разведочном замере](../../docs/benchmarks/2026-08-12-diarization-feasibility.md). [разведочном замере](../../docs/benchmarks/2026-08-12-diarization-feasibility.md).
+10 -13
View File
@@ -25,12 +25,11 @@ Package manager is **uv** (not pip). Build backend is hatchling.
``` ```
CLI (cli.py) CLI (cli.py)
→ config.py cascade: CLI arg → .transcriber.toml (model/compute_type stay None for the module to resolve) → config.py cascade: CLI arg → .transcriber.toml → device-aware default → hardcoded
→ 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 Transcriber(ExecutionRequest): resolves auto + device defaults, → transcriber.py load_model() → get_backend(device) → ensure_model_available → create_model
get_backend(device) → ensure_model_available → create_model once per run, _transcribe_file() with mid-stream CUDA→CPU fallback
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)
``` ```
@@ -48,21 +47,19 @@ Three backends implement the `Backend` Protocol (structural typing, no inheritan
### Key design decisions ### Key design decisions
- **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. - **Two-level fallback**: GPU→CPU at model load time AND mid-stream during transcription (GPU visible via nvidia-smi but insufficient VRAM).
- **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 → create `Transcriber` → transcribe all). No state is carried between files by the CLI. - **Batch mode**: 3-phase pipeline (prescan → load model once → transcribe all). `TranscribeFileResult` carries updated model/backend/device state between files.
- **Device-aware defaults**: `compute_type` and `model` vary by device (float16 for CUDA, int8 for OpenVINO, float32 for CPU). Defined in `config.py` `DEVICE_DEFAULTS`, applied by `Transcriber` when the request leaves them `None`. - **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`.
- **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` with the real `Transcriber` and a fake adapter patched at `local_transcriber.transcriber.get_backend`; `load_config`, `validate_input_file`, `write_transcript` mocked - CLI tests: `typer.testing.CliRunner` + mocks for `load_config`, `detect_device`, `load_model`, `_transcribe_file`, `write_transcript`
- `_cli_run()` — context manager assembling that standard set, yields `(backend, write_transcript)` - `_single_patches()` — helper assembling standard happy-path mock set
- `_make_result()` / `_make_backend()` — factories for test data - `_make_result()` / `_make_tfr()` — 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
@@ -82,7 +79,7 @@ All tests mock backends — no real model downloads or transcription. Key test p
### Issue tracker ### Issue tracker
Задачи ведутся в Gitea через `tea`; внешние PR не входят в triage. См. `docs/agents/issue-tracker.md`. Задачи ведутся в Gitea через `tea`; GitHub используется только как зеркало, внешние PR не входят в triage. См. `docs/agents/issue-tracker.md`.
### Triage labels ### Triage labels
+1 -21
View File
@@ -1,21 +1 @@
@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: Transcriber — разрешение устройства, загрузка модели один раз на запуск, транскрипция, fallback → transcriber.py: загрузка модели (с CUDA fallback), транскрипция
→ formatter.py: сегменты → markdown с таймкодами → formatter.py: сегменты → markdown с таймкодами
→ запись результата → запись результата
``` ```
## Ключевые архитектурные решения ## Ключевые архитектурные решения
- **Module выполнения** (`Transcriber` в `transcriber.py`) — один владелец модели, adapter'а и фактического исполнения на запуск. Fallback GPU→CPU (при загрузке и mid-stream) живёт внутри и доступен только при `strict_device=False` из Python API: явный device в CLI strict, `auto` — ONNX CPU. - **CUDA fallback** — двухуровневый: при загрузке модели и при транскрипции (mid-stream). GPU может быть видна через nvidia-smi, но не иметь достаточно VRAM.
- **Device-aware дефолты** — `model` и `compute_type` без явного значения разрешаются по устройству при загрузке (`float16`/`float32`). `float16` не работает на CPU, `float32` расточителен на GPU. - **Device-aware дефолты** — `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 → создание `Transcriber` → transcribe). Модель загружается один раз (~2-5 сек), невалидные файлы отсеиваются до загрузки; CLI не переносит состояние между файлами. - **Батч-режим** — 3 фазы (prescan → load model → transcribe). Модель загружается один раз (~2-5 сек), невалидные файлы отсеиваются до загрузки.
- **Ручной glob в utils** — typer на Windows не раскрывает `*.mp4`, поэтому глобы обрабатываются явно. - **Ручной glob в utils** — typer на Windows не раскрывает `*.mp4`, поэтому глобы обрабатываются явно.
## Тестирование ## Тестирование
- Все CLI-тесты через `typer.testing.CliRunner` с настоящим `Transcriber` и fake adapter, подменённым в `local_transcriber.transcriber.get_backend` (библиотеки не вызываются) - Все CLI-тесты через `typer.testing.CliRunner` + моки (faster-whisper не вызывается)
- Моки: `load_config`, `validate_input_file`, `write_transcript` - Моки: `load_config`, `validate_input_file`, `detect_device`, `ensure_model_available`, `load_model`, `_transcribe_file`, `write_transcript`
- Паттерн: `_cli_run()` — контекстный менеджер стандартного набора, отдаёт `(backend, write_transcript)` - Паттерн: `_single_patches()` — хелпер для стандартного happy-path набора моков
- `_make_result()` / `_make_backend()` — фабрики тестовых данных - `_make_result()` / `_make_tfr()` — фабрики тестовых данных
## Частые задачи ## Частые задачи
+42 -134
View File
@@ -8,7 +8,7 @@ transcribe meeting.mp4
``` ```
- **Полностью локально** — данные не покидают машину - **Полностью локально** — данные не покидают машину
- **ONNX на CPU по умолчанию** — CUDA и OpenVINO включаются явно - **Авто-ускорение** — NVIDIA CUDA при наличии GPU, иначе ONNX на CPU
- **Батч-режим** — обработка нескольких файлов за один вызов - **Батч-режим** — обработка нескольких файлов за один вызов
- **Разделение говорящих** — локальная диаризация по флагу `--diarize` - **Разделение говорящих** — локальная диаризация по флагу `--diarize`
- **Из проводника Windows** — пункт Transcribe в меню «Отправить» ([установка](#контекстное-меню-проводника-windows)) - **Из проводника Windows** — пункт Transcribe в меню «Отправить» ([установка](#контекстное-меню-проводника-windows))
@@ -26,42 +26,30 @@ 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://github.com/dementev-dev/local-transcriber uv tool install --python 3.13 git+https://git.dementev.space/ddmitry/local-transcriber.git
``` ```
Или из клона репозитория (`<URL>` — адрес репозитория, из которого вы ставите): Отдельно устанавливать Python не нужно: `uv` найдёт или скачает подходящий
CPython 3.13. Флаг `--python` указан явно, потому что `uv tool install` не
учитывает `.python-version` проекта.
```bash **3. Ускорение (ставится автоматически):**
git clone <URL>
cd local-transcriber
uv tool install --python 3.13 .
```
**3. Пункт `Transcribe` в меню «Отправить»** (Windows, по желанию): - **NVIDIA CUDA** (GPU): если есть GPU — транскрипция в 5-10× быстрее
- **Windows**: `winget install -e --id Nvidia.CUDA --version 12.9` (от администратора), перезапустить терминал
- **Linux / WSL2**: работает из коробки (нужен только драйвер: `nvidia-smi`)
- **Без NVIDIA GPU**: автоматически используется ONNX с GigaAM RNN-T на CPU.
Эта модель понимает только русскую речь
- **Другие языки без NVIDIA**: выбирайте Whisper явно —
`--device openvino-cpu --model medium` на x86 или `--device cpu --model medium`
на любой платформе
- **OpenVINO** для Intel GPU или x86 CPU остаётся доступен через явный
`--device openvino`, `--device openvino-gpu` или `--device openvino-cpu`
```bash **4. Готово:**
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
@@ -79,14 +67,7 @@ transcribe meeting.mp4
**Обновление:** **Обновление:**
```bash ```bash
uv tool install --python 3.13 --force git+https://github.com/dementev-dev/local-transcriber uv tool install --python 3.13 --force git+https://git.dementev.space/ddmitry/local-transcriber.git
```
Или в папке со склонированным репозиторием:
```bash
git pull
uv tool install --python 3.13 --force .
``` ```
**Удаление:** **Удаление:**
@@ -130,74 +111,17 @@ 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
# Простой запуск (ONNX GigaAM RNN-T на CPU, язык ru) # Простой запуск (CUDA medium или ONNX GigaAM RNN-T, язык ru)
transcribe meeting.mp4 transcribe meeting.mp4
# Указать язык # Указать язык
transcribe lecture.mp3 --device cpu --model medium --language en transcribe lecture.mp3 --language en
# Максимальное качество на NVIDIA GPU # Максимальное качество на NVIDIA GPU
transcribe podcast.wav --device cuda --model large-v3 --compute-type float16 transcribe podcast.wav --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
@@ -298,32 +222,15 @@ 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 и др.); автоматический профиль понимает только русский | | `--language` | `-l` | `ru` | Язык (ru, en, auto и др.); автоматический профиль без NVIDIA понимает только русский |
| `--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 (рекомендуется = число физ. ядер); для ONNX задаёт потоки и ASR, и VAD | | `--threads` | `-t` | 0 (авто) | Потоки CPU (рекомендуется = число физ. ядер) |
| `--diarize` / `--no-diarize` | — | `false` | Включить или отключить разделение на реплики говорящих | | `--diarize` | — | — | Разделить текст на реплики говорящих |
| `--speakers` | — | авто | Ожидаемое число говорящих; включает диаризацию и несовместим с `--no-diarize` | | `--speakers` | — | авто | Ожидаемое число говорящих; включает `--diarize` |
| `--force` | `-f` | — | Перезаписать существующие транскрипты | | `--force` | `-f` | — | Перезаписать существующие транскрипты |
| `--verbose` | `-v` | — | Подробный вывод: сегменты по ходу, движок, потоки, версии runtime | | `--verbose` | `-v` | — | Подробный вывод |
### Диагностика исполнения
Перед распознаванием программа печатает модель, устройство и тип вычислений,
которые выбраны фактически: после `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.
## Платформы ## Платформы
@@ -332,7 +239,7 @@ transcribe --uninstall-menu
| CPU через ONNX | ✅ авто | ✅ авто | ✅ авто | | CPU через ONNX | ✅ авто | ✅ авто | ✅ авто |
| OpenVINO (x86 CPU) | ✅ явно | — | ✅ явно | | OpenVINO (x86 CPU) | ✅ явно | — | ✅ явно |
| OpenVINO (Intel GPU) | ✅ явно | — | ✅ явно | | OpenVINO (Intel GPU) | ✅ явно | — | ✅ явно |
| GPU (NVIDIA) | extra + явно (x86_64) | — | extra + явно (x64; прогон без Toolkit ожидается) | | GPU (NVIDIA) | ✅ авто | — | ✅ (нужен CUDA 12) |
Данные по macOS основаны на доступности пакетов onnxruntime: прогонов на этой Данные по macOS основаны на доступности пакетов onnxruntime: прогонов на этой
платформе не было. платформе не было.
@@ -341,9 +248,9 @@ transcribe --uninstall-menu
<summary>Linux / WSL2</summary> <summary>Linux / WSL2</summary>
- **Intel GPU** (Arc, встроенная графика) работает из коробки через OpenVINO - **Intel GPU** (Arc, встроенная графика) работает из коробки через OpenVINO
- **NVIDIA GPU**: [extra `cuda` и явный выбор](#подключение-cuda), отдельно совместимый драйвер - **NVIDIA GPU** работает из коробки — cuBLAS ставится автоматически как зависимость
- `nvidia-smi` проверяет наличие драйвера, но не доказывает совместимость GPU с runtime - Нужен только драйвер NVIDIA (проверка: `nvidia-smi`)
- CUDA extra на ARM (aarch64) этим проектом не поддерживается - На ARM (aarch64) cuBLAS через pip недоступен — нужен системный CUDA toolkit
</details> </details>
@@ -360,9 +267,13 @@ transcribe --uninstall-menu
- CPU работает из коробки - CPU работает из коробки
- **Intel GPU** (Arc, встроенная графика) работает из коробки через OpenVINO - **Intel GPU** (Arc, встроенная графика) работает из коробки через OpenVINO
- **NVIDIA GPU**: [extra `cuda` и явный выбор](#подключение-cuda), отдельно совместимый драйвер - Для **NVIDIA GPU** нужен **CUDA 12** (ctranslate2 4.7 не совместим с CUDA 11 и 13):
- Требуется Visual C++ Runtime x64; системный CUDA Toolkit не нужен для extra ```
- Реальная транскрипция с extra без Toolkit пока не проверена на Windows winget install -e --id Nvidia.CUDA --version 12.9
```
> `winget install` требует запуска от имени администратора (elevated terminal).
> `uv tool install` работает без админа (ставит в пользовательскую директорию).
- После установки CUDA перезапустите терминал
</details> </details>
@@ -375,7 +286,6 @@ device = "openvino-cpu"
model = "large-v3-turbo" model = "large-v3-turbo"
compute_type = "int8" compute_type = "int8"
language = "ru" language = "ru"
diarize = true
``` ```
Порядок поиска: Порядок поиска:
@@ -383,11 +293,9 @@ diarize = true
2. `~/.config/transcriber/config.toml` 2. `~/.config/transcriber/config.toml`
Приоритет: **CLI-аргумент > конфиг > device-aware дефолт > встроенный дефолт**. Приоритет: **CLI-аргумент > конфиг > device-aware дефолт > встроенный дефолт**.
`--diarize` и `--no-diarize` позволяют переопределить `diarize` из конфига для
отдельного запуска.
При `device = "auto"` всегда выбирается ONNX CPU, даже если доступны При `device = "auto"` выбирается CUDA, если доступен `nvidia-smi`, иначе ONNX.
`nvidia-smi` и extra `cuda`. Другие варианты выбираются явно из CLI или конфига. Явный `device` из CLI или конфига отключает этот автоматический выбор.
Каталоги моделей различаются между бэкендами, поэтому при закреплении `model` Каталоги моделей различаются между бэкендами, поэтому при закреплении `model`
в конфиге рекомендуется явно закрепить и совместимый `device`. То же с языком: в конфиге рекомендуется явно закрепить и совместимый `device`. То же с языком:
автоматический ONNX-профиль рассчитан на русскую речь, а для остальных языков автоматический ONNX-профиль рассчитан на русскую речь, а для остальных языков
@@ -404,9 +312,9 @@ diarize = true
## Модели и GPU ## Модели и GPU
Рекомендации: Рекомендации:
- **По умолчанию:** ONNX `gigaam-v3-e2e-rnnt` — читаемый русский текст с - **По умолчанию без CUDA:** ONNX `gigaam-v3-e2e-rnnt` — читаемый русский текст с
пунктуацией почти без потери скорости относительно сырого `gigaam-v3` пунктуацией почти без потери скорости относительно сырого `gigaam-v3`
- **Макс. качество (NVIDIA):** `--device cuda --model large-v3 --compute-type float16` - **Макс. качество (NVIDIA):** `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 (всегда ONNX CPU) По умолчанию: auto (CUDA при наличии, иначе 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 + CUDA | Не поддерживается текущей сборкой; см. [совместимость](gpu.md#совместимость-gpu) | | Quadro M3000M + large-v3 | ~3-5x (1 час аудио ≈ 12-20 мин) |
| 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 | ❌ | ❌ | ONNX CPU или OpenVINO | | Quadro M3000M | 4 GB | ✅ | ⚠️ может OOM | int8 обязательно |
| Без 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
- Для NVIDIA GPU на Linux/WSL x86_64 и Windows x64: совместимый драйвер, extra `cuda` и явный выбор CUDA (см. ADR-001); Windows также требует Visual C++ Runtime x64 - Для GPU: Linux/WSL2 — cuBLAS из nvidia-cublas-cu12 (ставится автоматически через `uv sync`); Windows — системный CUDA toolkit (см. ADR-001)
- Дисковое пространство для моделей: зависит от выбранного 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 | Среднее | Явная CUDA сохраняет причину ошибки; пользователь выбирает CPU или исправляет окружение по README | | CUDA несовместимость на Windows | Среднее | Автоматический fallback на CPU + предупреждение; в README — инструкция по CUDA |
| Большие файлы (>2 часов) | Среднее | Учитывать память выбранного backend; чанкование рассматривается отдельно | | Большие файлы (>2 часов) | Среднее | Учитывать память выбранного backend; чанкование рассматривается отдельно |
| OOM на GPU с 4 GB VRAM | Среднее | CUDA использует float16; при OOM явной CUDA — ошибка с подсказкой выбрать меньшую модель или CPU | | OOM на GPU с 4 GB VRAM | Среднее | CUDA использует float16; при OOM — fallback на CPU с предупреждением или явный более лёгкий профиль |
| Файл без речи (тишина, музыка, шум) | Низкое | Создаётся транскрипт с шапкой метаданных и `*Речь не обнаружена.*` в теле + предупреждение в stderr | | Файл без речи (тишина, музыка, шум) | Низкое | Создаётся транскрипт с шапкой метаданных и `*Речь не обнаружена.*` в теле + предупреждение в stderr |
## 7. Вне скоупа MVP ## 7. Вне скоупа MVP
+52 -108
View File
@@ -1,134 +1,78 @@
# ADR-001: Необязательная CUDA и загрузка cuBLAS из окружения # ADR-001: Preload libcublas из pip-пакета для GPU на Linux/WSL2
**Статус**: Принято; пересмотр обязательной cuBLAS от 2026-03-18 **Статус**: Принято
**Дата**: 2026-03-18 **Дата**: 2026-03-18
**Обновлено**: 2026-09-21, задача [#2](https://git.dementev.space/ddmitry/local-transcriber/issues/2)
## Контекст ## Контекст
CTranslate2 загружает cuBLAS динамически и не поставляет её в своём wheel. ctranslate2 (backend faster-whisper) в runtime делает `dlopen("libcublas.so.12")`,
Прежняя обязательная зависимость добавляла около 581 MB скачивания на Linux но не бандлит эту библиотеку в свой wheel — ожидает её в системе.
x86_64, даже если пользователь работал только через ONNX CPU. Без установленного CUDA toolkit `uv run transcribe --device cuda` падает с ошибкой.
Наличие `nvidia-smi` доказывает наличие утилиты драйвера, но не совместимость GPU
с runtime. Автоматический выбор CUDA мог приводить к ошибке на старом GPU.
Прежнее утверждение о недоступности Windows wheel `nvidia-cublas-cu12` было Ключевые факты:
ошибочным: такой wheel есть и для закреплённой версии 12.9.1.4. Следовательно, - `libcuda.so.1` приходит от NVIDIA driver (всегда есть, если GPU есть)
системный CUDA Toolkit не должен быть обязательным способом подключения Windows. - `libcublas.so.12` отсутствует в wheel ctranslate2 на обеих платформах
- **cuDNN не нужен** — в `libctranslate2.so` ноль символов cudnn (проверено `nm -D` и `strings`)
- На Windows ctranslate2 делает `os.add_dll_directory` в своём `__init__.py`,
но только для DLL внутри пакета; `cublas64_12.dll` тоже не бандлится
## Решение ## Решение
CUDA подключается через extra `cuda` на Linux/WSL x86_64 и Windows x64. ### nvidia-cublas-cu12 как pip-зависимость
Драйвер NVIDIA остаётся внешним требованием; Windows также требует Visual C++
Runtime x64. Установка extra не включает CUDA: пользователь выбирает её через
CLI или TOML. `auto` всегда выбирает ONNX CPU, ASR и VAD получают явный
`CPUExecutionProvider`. Команды установки и обновления — в
[README](../../README.md#подключение-cuda).
CTranslate2 4.8.1 и cuBLAS 12.9.1.4 закреплены в `pyproject.toml`, чтобы В `pyproject.toml`:
установка через `uv tool` и из клона использовала проверенное сочетание. ```
В cuBLAS 12.9.2.10 появилась дополнительная зависимость NVRTC. Обновление "nvidia-cublas-cu12>=12.4; sys_platform == 'linux' and platform_machine == 'x86_64'"
версий требует повторной проверки библиотек и bootstrap; одного обновления ```
lock-файла недостаточно.
### Linux: preload по полному пути Нижняя граница `>=12.4` — ctranslate2 собран с CUDA 12.4. Пакет доступен только
для Linux x86_64 (на Windows и macOS не устанавливается по platform marker).
`_cuda_bootstrap.py` загружает `libcublas.so.12` через `ctypes.CDLL` с ### ctypes.CDLL preload вместо LD_LIBRARY_PATH
`RTLD_GLOBAL`. Линкер затем разрешает запрос CTranslate2 по soname.
Изменение `LD_LIBRARY_PATH` внутри процесса не решает задачу: glibc кеширует
пути поиска. Путь optional-пакета определяется через `nvidia.cublas.__path__`,
что поддерживает namespace package без `__file__`.
### Windows: регистрация каталога и preload DLL `_cuda_bootstrap.py` загружает `libcublas.so.12` по полному пути через
`ctypes.CDLL(path, mode=RTLD_GLOBAL)` **до** первого `import ctranslate2`.
Bootstrap регистрирует `nvidia/cublas/bin` через `os.add_dll_directory`, затем Почему не `os.environ["LD_LIBRARY_PATH"]`: на Linux/glibc динамический линкер (`ld.so`)
загружает по абсолютным путям сначала `cublasLt64_12.dll`, затем кеширует пути поиска при старте процесса и **не перечитывает** `LD_LIBRARY_PATH`
`cublas64_12.dll`. Handles библиотек и регистрации каталога хранятся до из environ в рамках уже запущенного процесса.
завершения процесса. Повторный вызов не загружает их заново. Это обеспечивает
доступность уже загруженных DLL для динамического загрузчика CTranslate2 и
не требует изменения системного PATH.
Bootstrap вызывается только при создании CUDA-модели, до локального импорта Почему `RTLD_GLOBAL`: без этого флага символы cublas не видны другим `.so`,
`WhisperModel`. Если CTranslate2 ранее импортирован для декодирования аудио, загруженным позже (в т.ч. `libctranslate2.so`).
preload всё равно выполняется до создания CUDA-модели. CPU-путь bootstrap не
вызывает. Отсутствие optional-пакета безопасно: системная cuBLAS всё ещё может
обслужить явно выбранную CUDA. Ошибка загрузки найденной Windows DLL сохраняется.
### Ошибки и явный выбор Динамический линкер кеширует загруженные библиотеки по soname — когда ctranslate2
потом вызовет `dlopen("libcublas.so.12")`, линкер вернёт уже загруженный handle.
Явный `device` из CLI или TOML передаёт `strict_device=True` при загрузке и ### strict_device для явного --device
транскрипции. CUDA не заменяется молча на CPU. Подсказки различают отсутствие
библиотек, проблемы драйвера, несовместимость GPU/runtime и нехватку памяти;
отказ по неподдерживаемому типу вычислений учитывает устройство, чтобы
одинаковая ошибка CTranslate2 на CPU не получала CUDA-подсказку. Исходное
сообщение сохраняется. Неизвестная ошибка не получает произвольный
совет установить Toolkit. Это одинаково для одного файла и батча.
Прежний механизм fallback оркестратора при `strict_device=False` сохранён `--device cuda` / `--device cpu` → `strict_device=True` → CUDA-ошибка = raise, без fallback.
для существующих программных callers. Автоматический CLI-путь теперь ONNX, `--device auto` → `strict_device=False` → текущее поведение с fallback на CPU.
поэтому он не попадает в CUDA fallback.
## Проверка зависимостей Windows Мотивация: silent fallback для часового файла = 60 минут вместо 5.
Статическая проверка 2026-09-21: скачаны wheels из PyPI, проверены SHA-256, ## Последствия и tradeoffs
для DLL/PYD прочитаны PE imports через `objdump -p` и строки динамической
загрузки. Модели распознавания не скачивались.
- `ctranslate2-4.8.1-cp313-cp313-win_amd64.whl`, SHA-256 **~400 MB на CPU-only Linux x86_64**: nvidia-cublas-cu12 ставится на все Linux x86_64,
`d52499f05a60a791aeadee28d609efa130142f376d1ea76b2b1c593bb01f8827`. включая машины без GPU. Bootstrap при этом preload'ит libcublas (overhead ~1 ms),
`ctranslate2.dll` содержит имена `cublas64_12.dll` и `nvcuda.dll`; но она не используется, т.к. ctranslate2 не получит запрос на CUDA device.
ссылок на 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.
Основания: [CTranslate2 4.8.1 на PyPI](https://pypi.org/project/ctranslate2/4.8.1/#files), Альтернатива — `[project.optional-dependencies]` + `uv sync --extra cuda`,
[cuBLAS 12.9.1.4 на PyPI](https://pypi.org/project/nvidia-cublas-cu12/12.9.1.4/#files), но тогда теряется zero-config UX. Для v1 оставляем как обязательную зависимость.
[загрузчик 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).
Это обоснование состава extra для конкретных wheels, а не подтверждение **Windows GPU**: nvidia-cublas-cu12 недоступен как pip-пакет для Windows.
транскрипции на Windows GPU. Реальный прогон Windows x64 с драйвером и extra, Единственный путь — системный CUDA toolkit (`choco install cuda` / `winget install -e --id Nvidia.CUDA`).
без Toolkit и ручной правки PATH, **не выполнен**. Linux-тесты с подменой CLI выводит эту подсказку при CUDA-ошибке на `sys.platform == "win32"`.
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 |---|---|
и Windows, с extra и без, подтверждает отсутствие NVIDIA-пакетов в обычной | `nvidia-cudnn-cu12` в зависимостях | ctranslate2 не использует cuDNN — ноль символов, проверено через `nm -D` |
установке и наличие только cuBLAS 12.9.1.4 при выборе extra. | Self-reexec с `LD_LIBRARY_PATH` | ctypes.CDLL решает задачу без перезапуска процесса; self-reexec создаёт проблемы с сигналами, tty, fd |
| `os.environ["LD_LIBRARY_PATH"] += ...` | glibc кеширует пути при старте, не перечитывает environ |
Формы `uv tool install` для клона, клона с extra и именованной Git-зависимости | `DeviceResolution` dataclass + `resolve_device()` | Один `bool strict_device` решает ту же задачу проще |
с extra проверены в отдельном временном каталоге tools. Для проверки самой | ctranslate2 preflight (`get_supported_compute_types`) | Латентность; try/catch при загрузке модели не хуже |
команды зависимости были исключены; их разрешение проверено отдельно выше. | 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 в эту задачу не входит.
+16 -24
View File
@@ -2,7 +2,6 @@
**Статус**: Принято **Статус**: Принято
**Дата**: 2026-03-18 **Дата**: 2026-03-18
**Обновлено**: 2026-09-21 — решения 2 и 4 пересмотрены (см. пометки)
## Контекст ## Контекст
@@ -18,23 +17,19 @@
**Почему**: загрузка модели (large-v3) занимает ~10 секунд и ~3 GB RAM/VRAM. Повторный запуск по уже обработанным файлам должен быть дешёвым no-op. **Почему**: загрузка модели (large-v3) занимает ~10 секунд и ~3 GB RAM/VRAM. Повторный запуск по уже обработанным файлам должен быть дешёвым no-op.
### 2. Состояние модели между файлами ### 2. TranscribeFileResult: возврат обновлённого состояния модели
**Пересмотрено 2026-09-21.** Исходное решение: `_transcribe_file()` возвращал При mid-stream CUDA fallback `_transcribe_file()` перезагружает модель на CPU внутри себя. Чтобы следующие файлы в батче не грузили модель повторно, результат включает обновлённые `model` и `actual_device`.
`TranscribeFileResult` с обновлёнными `model`, `backend`, `model_path`
и `actual_device`, а CLI переносил их в следующий файл после mid-stream
fallback. Это делало caller владельцем согласованности четырёх полей.
Теперь состояние не покидает module выполнения: `Transcriber` создаётся один ```python
раз на запуск после prescan, загружает модель при первом файле и сам держит @dataclass
загруженную модель, adapter и фактическое исполнение. Fallback меняет class TranscribeFileResult:
состояние внутри module, и следующий файл батча идёт уже по нему. Caller result: TranscribeResult
получает `TranscribeResult` и `ExecutionInfo` (запрошенное и выбранное model: WhisperModel # может измениться при fallback
исполнение, движок, модель, квантизация, потоки, версии runtime). actual_device: str # может измениться при fallback
```
**Альтернатива**: mutable-контейнер с моделью, передаваемый по ссылке, — **Альтернатива**: передавать модель по ссылке через mutable контейнер — менее явно и сложнее тестировать.
отклонена тогда же; owner-объект решает ту же задачу явно и тестируется
через свой interface.
### 3. Конфиг: CWD → глобальный, без мержа ### 3. Конфиг: CWD → глобальный, без мержа
@@ -48,18 +43,15 @@ fallback. Это делало caller владельцем согласованн
**Приоритет значений**: CLI > конфиг > хардкод. **Приоритет значений**: CLI > конфиг > хардкод.
### 4. Публичный `transcribe()` — путь одного файла ### 4. `_transcribe_file()` — внутренний helper
**Пересмотрено 2026-09-21.** Внутренние helpers `load_model()` Публичный API (`transcribe()`) сохранён без изменений. Новая функция `_transcribe_file()` — внутренний helper с префиксом `_`, не часть публичного контракта.
и `_transcribe_file()` удалены. `transcribe()` сохранён и стал обёрткой над
тем же `Transcriber`: `model_name` и `compute_type` без значения берут `transcribe()` стала тонкой обёрткой: `load_model()` + `_transcribe_file()` → `TranscribeResult`.
умолчания выбранного исполнения, `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` флаг для перезаписи существующих транскриптов в батч-режиме
+15 -34
View File
@@ -2,7 +2,7 @@
**Статус**: Принято **Статус**: Принято
**Дата**: 2026-03-21 **Дата**: 2026-03-21
**Обновлено**: 2026-09-21 **Обновлено**: 2026-08-12
## Контекст ## Контекст
@@ -32,9 +32,8 @@ Protocol вместо ABC — бэкенды не наследуются, дос
### Ленивые импорты ### Ленивые импорты
Бэкенды импортируются только при выборе — `get_backend(device)` делает import внутри. Бэкенды импортируются только при выборе — `get_backend(device)` делает import внутри.
CUDA bootstrap вызывается только при создании CUDA-модели (см. ADR-001). Импорт faster-whisper запускает CUDA bootstrap (~1ms), импорт openvino-genai загружает ~50MB
Импорт openvino-genai загружает ~50MB shared libraries и не должен происходить, shared libraries. Ни то, ни другое не должно происходить, если бэкенд не выбран.
если бэкенд не выбран.
### Device как селектор бэкенда ### Device как селектор бэкенда
@@ -42,41 +41,23 @@ CUDA bootstrap вызывается только при создании CUDA-м
- `cuda`, `cpu` → FasterWhisperBackend - `cuda`, `cpu` → FasterWhisperBackend
- `openvino` → OpenVINOBackend - `openvino` → OpenVINOBackend
- `onnx` → OnnxAsrBackend - `onnx` → OnnxAsrBackend
- `auto` → ONNX на CPU независимо от `nvidia-smi` и установки extra - `auto` → CUDA при наличии `nvidia-smi`, иначе ONNX на CPU
### Transcriber — единственный владелец загрузки и исполнения ### load_model() — единственный владелец pipeline
Обновлено 2026-09-21. `Transcriber(ExecutionRequest)` в `transcriber.py` `load_model()` выполняет ensure_model_available + create_model в одном вызове.
разрешает `auto` и device-aware умолчания, выполняет ensure_model_available + CLI не вызывает ensure_model_available отдельно — это убирает двойной resolution
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`, не в бэкендах и не в CLI: Fallback живёт в `transcriber.py` (оркестратор), не в бэкендах:
- CUDA ошибка → CPU (FasterWhisper) - CUDA ошибка → CPU (FasterWhisper)
- OpenVINO ошибка → CPU (FasterWhisper) - OpenVINO ошибка → CPU (FasterWhisper)
- strict запуск (явный device из CLI или TOML) → ошибка без fallback - `strict_device=True` (явный `--device`) → ошибка без fallback
После перехода module сам держит новое состояние; следующий файл батча При fallback в батч-режиме обновляются model, backend, model_path и actual_device
использует его без участия caller. `WordTimestampsUnavailableError` через TranscribeFileResult — следующий файл использует правильный бэкенд.
и ошибки пользовательских данных переходом не считаются.
### Диагностика исполнения
Каждый adapter отдаёт `runtime_info()` — версии runtime и фактическую
конфигурацию (для ONNX — доступные providers и те, что заданы сессиям ASR
и VAD, квантизация, бюджет потоков). `Transcriber` включает их в
`ExecutionInfo`; CLI печатает их в `--verbose`. Наличие provider в wheel не
считается доказательством выполнения графа на GPU/NPU.
### Аудио для OpenVINO ### Аудио для OpenVINO
@@ -95,13 +76,13 @@ OpenVINO модели предквантизированы (int8/fp16), compute_
### Зависимости бэкендов по умолчанию ### Зависимости бэкендов по умолчанию
faster-whisper, onnx-asr/onnxruntime и openvino-genai ставятся вместе. Модели faster-whisper, onnx-asr/onnxruntime и openvino-genai ставятся вместе. Модели
скачиваются только для активного бэкенда. cuBLAS подключается через extra `cuda` скачиваются только для активного бэкенда. CUDA (`nvidia-cublas-cu12`) остаётся
для Linux/WSL x86_64 и Windows x64 (см. ADR-001). OpenVINO — conditional для x86_64/AMD64, кроме conditional для Linux x86_64. OpenVINO — conditional для x86_64/AMD64, кроме
macOS; ONNX обеспечивает автоматический CPU-путь на остальных платформах. macOS; ONNX обеспечивает автоматический CPU-путь на остальных платформах.
## Последствия ## Последствия
- Обратная совместимость: `transcribe()` сохранён; `load_model()` и `_transcribe_file()` удалены (2026-09-21) - Обратная совместимость: `transcribe()` сохранён; `load_model()` изменил сигнатуру (возвращает 4-tuple вместо 2-tuple, добавлен `compute_type_explicit`)
- Новый бэкенд добавляется одним файлом в `backends/` + регистрацией в `__init__.py` - Новый бэкенд добавляется одним файлом в `backends/` + регистрацией в `__init__.py`
- Модели скачиваются по запросу — CUDA пользователь не качает OpenVINO модели, и наоборот - Модели скачиваются по запросу — CUDA пользователь не качает OpenVINO модели, и наоборот
- ARM и macOS: OpenVINO не ставится (platform markers), auto использует ONNX - ARM и macOS: OpenVINO не ставится (platform markers), auto использует ONNX
+3 -5
View File
@@ -102,12 +102,10 @@ Mm-hmm-редукция и иностранные вставки **не обна
## Решение ## Решение
**Принять onnx-asr как CPU-бэкенд автоматического профиля. **Принять onnx-asr как CPU-бэкенд автоматического профиля.
GigaAM v3 E2E RNN-T — модель автоматического профиля на всех машинах.** GigaAM v3 E2E RNN-T — модель по умолчанию на машинах без CUDA.**
С 2026-09-21 `--device auto` всегда выбирает ONNX CPU; для ASR и VAD явно Порядок `--device auto`: CUDA при наличии `nvidia-smi`, иначе ONNX. OpenVINO и
передаётся `CPUExecutionProvider`, независимо от доступных providers. CUDA, FasterWhisper CPU остаются доступными через явный CLI-аргумент или конфиг.
OpenVINO и FasterWhisper CPU доступны через явный CLI-аргумент или конфиг.
Подключение CUDA описано в [ADR-001](001-cuda-bootstrap.md).
GigaAM понимает только русскую речь, поэтому для остальных языков автоматический GigaAM понимает только русскую речь, поэтому для остальных языков автоматический
профиль не годится — нужен явный Whisper. Несовместимый язык работу не профиль не годится — нужен явный Whisper. Несовместимый язык работу не
+1
View File
@@ -5,6 +5,7 @@
- Основной 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
## Доступ ## Доступ
@@ -1,125 +0,0 @@
# Бенчмарк: качество и скорость транскрипции
**Дата**: 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 или аналоги).
@@ -1,75 +0,0 @@
# Streaming Sortformer: сравнение на Ryzen
**Дата:** 2026-09-04
Streaming Sortformer обработал три 300-секундных фрагмента за `67,433` с
против `103,242` с у свежего W0. Ускорение одной пары составило `34,68%`.
Кандидат правильно определил число голосов на всех трех фрагментах; W0 на
одном из них выделил два кластера вместо трех.
У результата есть два заметных минуса. Sortformer оставил без говорящего
`92` слова против `73` у W0 и потребовал около `551` МиБ памяти против `325`
МиБ. Во время обеих ячеек была небольшая активность swap, поэтому одна пара
показывает направление, но не устойчивость ускорения.
## Сравнение
| Показатель | W0 | Sortformer |
| --- | ---: | ---: |
| Суммарный wall, с | `103,242` | `67,433` |
| Ускорение | - | `34,68%` |
| Ожидалось кластеров | `2 / 2 / 3` | `2 / 2 / 3` |
| Получено кластеров | `2 / 2 / 2` | `2 / 2 / 3` |
| Ошибок по числу кластеров | `1 из 3` | `0 из 3` |
| Mapped purity на двух размеченных фрагментах | `0,7655 / 0,8915` | `0,8392 / 0,9176` |
| Слов без говорящего | `73` | `92` |
| Пиковый RSS, МиБ | `324,57` | `551,07` |
| CPU time, с | `822,67` | `238,70` |
| Прочитано из swap, КиБ | `492` | `224` |
| Записано в swap, КиБ | `148` | `160` |
Обе модели получили одинаковые заранее подготовленные mono/16 kHz float32
буферы и те же ASR-sidecar. W0 работал на восьми физических ядрах, Sortformer -
с четырьмя закрепленными внутренними потоками. Загрузка моделей, декодирование
аудио, чтение sidecar и расчет метрик в wall не входили.
Sortformer прошел автоматические проверки формы вероятностей, диапазона
значений, меток говорящих, текста, порядка слов и интервалов на всех трех
фрагментах. Его mapped purity выше W0 на обоих фрагментах с опорной разметкой.
При этом рост числа слов без говорящего требует слуховой проверки: по одной
автоматике нельзя заключить, что качество кандидата в целом лучше.
## Как читать результат
По исходному расписанию задача завершилась как `stop`, когда W0 не прошел
проверку числа кластеров. После этого была выполнена одна диагностическая
ячейка Sortformer на тех же входах, чтобы ответить на практический вопрос о
кандидате. Пять повторных пар, полные записи и слуховая проверка не выполнялись.
По этой паре Sortformer выглядит перспективно: ускорение оказалось выше
целевого порога `30%`, модель правильно выделила три голоса на проблемном
фрагменте и показала лучшую mapped purity. Для решения о внедрении нужны
повторные парные замеры без активности swap и слуховая проверка мест, где
слова остались без говорящего. Текущий результат уже достаточен, чтобы не
отбрасывать кандидата и продолжить исследование отдельной задачей.
## Рецепты и техническая привязка
W0 использовал `sherpa-onnx/core 1.13.6`, Pyannote Segmentation 3.0 FP32,
WeSpeaker ResNet34 LM FP32, CPU provider, `window_shift_ratio=0.1`,
`FastClustering(auto, 0.89)`, один процесс и одну сессию,
`intra_op_threads=8`, `inter_op_threads=1`, `min_duration_on=0.3` и
`min_duration_off=0.5`.
Кандидат использовал NeMo-Speech.cpp commit
`4f9676226f667d14608487df744f375db87127f8`, модель Streaming Sortformer v2
Q8_0 с SHA-256
`0679cfeb1ce356d0dea9470b31274f4bfc7eb927497d82005483770666da998a`, CPU
backend, один процесс и один stream. Geometry и post-processing соответствовали
[спецификации](../specs/2026-09-01-streaming-sortformer-cpu-experiment.md).
Коммит исходного кода, выполнившего обе ячейки:
`4c4a8be07b3653cede35b0435e6c9ef6cb3fb87d`.
Закрытая основа и результаты сохраняются для следующих исследований. Их
повторное использование должно быть явно описано в новой задаче.
+26 -25
View File
@@ -2,7 +2,7 @@
## Режимы `--device` ## Режимы `--device`
- `auto` (по умолчанию) — всегда ONNX на CPU - `auto` (по умолчанию) — CUDA при наличии `nvidia-smi`, иначе 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) | `cuda` + extra | faster-whisper (CTranslate2) | 7-19x реалтайм | | NVIDIA GPU (6+ GB VRAM) | `auto` / `cuda` | faster-whisper (CTranslate2) | 7-19x реалтайм |
| Любой CPU | `auto` / `onnx` | ONNX GigaAM RNN-T | 10-14x реалтайм* | | Любой CPU без NVIDIA | `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, модели и записи.
Автоматический профиль рассчитан на русскую речь: GigaAM других Автоматический профиль без NVIDIA рассчитан на русскую речь: GigaAM других
языков не понимает. Для них берите Whisper — `openvino-cpu` на x86 или `cpu` языков не понимает. Для них берите Whisper — `openvino-cpu` на x86 или `cpu`
на любой платформе. на любой платформе.
@@ -211,16 +211,22 @@ SMT/Hyper-Threading не помогает — 16 потоков на 8-ядер
## Настройка по платформам ## Настройка по платформам
Для Linux/WSL x86_64 и Windows x64 используется один extra `cuda` и явный ### Linux / WSL2 (x86_64)
выбор `--device cuda` либо `device = "cuda"` в TOML. Команды установки из Git,
клона и для разработки приведены в [README](../README.md#подключение-cuda).
Обычный запуск всегда использует ONNX CPU, независимо от драйвера и extra.
Драйвер NVIDIA устанавливается отдельно. Полный системный CUDA Toolkit не Библиотека cuBLAS ставится автоматически (зависимость `nvidia-cublas-cu12` подтягивается при установке).
нужен для пути через extra; на Windows требуется Visual C++ Runtime x64. Дополнительных шагов не требуется.
Состав Windows DLL проверен статически; реальная транскрипция без Toolkit
пока не проверена. Версии и границы проверки — [ADR-001](adr/001-cuda-bootstrap.md). > На ARM (aarch64) cuBLAS через pip недоступен — нужен системный CUDA toolkit.
CUDA extra для ARM в этой задаче не поддерживается.
### Windows
Нужен системный **CUDA 12** (ctranslate2 4.7 не совместим с CUDA 11 и 13):
```bash
winget install -e --id Nvidia.CUDA --version 12.9 # требует запуска от имени администратора
```
После установки перезапустите терминал.
## Совместимость GPU ## Совместимость GPU
@@ -325,19 +331,14 @@ threshold не помогла — проблема inherent для модели
Убедитесь, что `nvidia-smi` возвращает информацию о GPU. Драйвер NVIDIA предоставляет `libcuda.so.1`, Убедитесь, что `nvidia-smi` возвращает информацию о GPU. Драйвер NVIDIA предоставляет `libcuda.so.1`,
без которого CUDA не работает — его нельзя поставить через pip. без которого CUDA не работает — его нельзя поставить через pip.
### Отсутствующие библиотеки и несовместимый GPU ### Windows: ошибка при загрузке модели на GPU
При отсутствии cuBLAS подключите extra в том окружении, откуда запускается GPU на Windows требует **CUDA 12** (ctranslate2 4.7 не совместим с CUDA 11 и 13). Установите:
программа, по [инструкции README](../README.md#подключение-cuda). ```bash
На Windows ошибка загрузки DLL может также означать отсутствие Visual C++ Runtime. winget install -e --id Nvidia.CUDA --version 12.9 # требует запуска от имени администратора
```
`no kernel image` / `invalid device function` означают несовместимость GPU После установки перезапустите терминал.
с выбранной сборкой runtime. Установка extra её не исправит. Ошибки драйвера
требуют проверки драйвера; нехватка VRAM — меньшей модели или явного выбора CPU.
Исходная ошибка сохраняется. CLI/TOML с явной CUDA не переходят молча на CPU
ни при загрузке, ни при распознавании.
### ARM (aarch64) ### ARM (aarch64)
CUDA extra не поддерживается проектом на этой архитектуре. Наличие отдельных cuBLAS через pip недоступен на ARM — нужен системный CUDA toolkit.
NVIDIA wheels для ARM само по себе не подтверждает рабочий путь транскрипции.
@@ -1,353 +0,0 @@
# Исследование: ускорение на 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-поддержкой
+4 -6
View File
@@ -23,7 +23,7 @@
## Не входит ## Не входит
- Аппаратно-зависимое автоматическое включение диаризации. - Автоматическое включение диаризации без флага и `--no-diarize`.
- Параллельный запуск ASR и диаризации. - Параллельный запуск ASR и диаризации.
- Сопоставление `Speaker N` с именами участников. - Сопоставление `Speaker N` с именами участников.
- Постоянный кеш разметки говорящих, голосовые эмбеддинги и диагностические - Постоянный кеш разметки говорящих, голосовые эмбеддинги и диагностические
@@ -33,12 +33,10 @@
## Пользовательский интерфейс ## Пользовательский интерфейс
- `--diarize` включает диаризацию, `--no-diarize` явно выключает. Булев ключ - `--diarize` включает диаризацию. По умолчанию она выключена; ключ в
`diarize` в `.transcriber.toml` задаёт значение по умолчанию; без него `.transcriber.toml` в первой версии не добавляется.
диаризация выключена. CLI-флаги имеют приоритет над конфигом.
- `--speakers N`, где `N >= 1`, задаёт известное число участников и сам включает - `--speakers N`, где `N >= 1`, задаёт известное число участников и сам включает
диаризацию. Без него число кластеров определяется автоматически. Сочетание диаризацию. Без него число кластеров определяется автоматически.
`--speakers N` с `--no-diarize` считается ошибкой использования.
- `--threads N` остаётся единым бюджетом активного CPU-прохода. Значение целиком - `--threads N` остаётся единым бюджетом активного CPU-прохода. Значение целиком
получает сначала ASR, затем диаризация; `0` оставляет настройки библиотек. получает сначала ASR, затем диаризация; `0` оставляет настройки библиотек.
- `--verbose` показывает в консоли прогресс, число кластеров и интервалов, - `--verbose` показывает в консоли прогресс, число кластеров и интервалов,
@@ -151,10 +151,9 @@
За пределами Git остаются manifest, записи, WAV-кеш, ASR-sidecar, опорные За пределами Git остаются manifest, записи, WAV-кеш, ASR-sidecar, опорные
транскрипты, сырые интервалы, результаты, worker requests, журналы и материалы транскрипты, сырые интервалы, результаты, worker requests, журналы и материалы
слуховой проверки. Рабочий каталог стенда нельзя размещать в `.scratch`: этот слуховой проверки. Рабочий каталог стенда нельзя размещать в обычной
каталог локален и целиком игнорируется, поэтому эксперимент из него не `.scratch`: репозиторий игнорирует там только служебный каталог
переносится между ноутбуками. Временно закрепленные диагностические скрипты уже `.scratch/handoffs/`, не связанный с переносом эксперимента между ноутбуками.
отслеживаются Git и не меняют этого правила для новых материалов.
Публичный отчет содержит хеши, агрегаты, конфигурацию и выводы. В него не входят Публичный отчет содержит хеши, агрегаты, конфигурацию и выводы. В него не входят
локальные пути, распознанный текст, временные интервалы, имена участников и локальные пути, распознанный текст, временные интервалы, имена участников и
@@ -186,25 +186,20 @@ mono/16 kHz float32. Публичные документы получают то
после единственного закрепленного преобразования, а ни один путь, ID или после единственного закрепленного преобразования, а ни один путь, ID или
интервал не попадает в stdout режима `--validate-only`. интервал не попадает в stdout режима `--validate-only`.
### Шаг 2. Подготовить ASR-sidecar и доступную опорную разметку ### Шаг 2. Создать ASR-sidecar и опорную разметку
Действующий ASR выполняется один раз для каждой полной записи до измерений. Действующий ASR выполняется один раз для каждой полной записи до измерений.
Sidecar хранит канонические слова с временной привязкой и нормализованный текст; Sidecar хранит канонические слова с временной привязкой и нормализованный текст;
fragment-sidecar получается детерминированным срезом той же версии. Затем fragment-sidecar получается детерминированным срезом той же версии. Затем
человек прослушивает каждый фрагмент целиком и фиксирует ожидаемое число человек прослушивает каждый фрагмент целиком, фиксирует ожидаемое число
голосовых кластеров для фрагментов и полных записей. Опорная разметка фрагмента голосовых кластеров для фрагментов и полных записей и создает опорную разметку,
необязательна: `reference_path` и `reference_sha256` либо заполнены вместе, либо достаточную для mapped purity и выбора мест прослушивания. Опорная разметка
оба равны `null`. При наличии разметки стенд считает mapped purity и выбирает остается приближенной и не объявляется истиной границ.
места прослушивания; сама разметка остается приближенной и не объявляется
истиной границ.
Критерий завершения: для каждого фрагмента и каждой полной записи есть Критерий завершения: для каждого фрагмента и каждой полной записи есть
подтвержденное человеком `expected_cluster_count` в диапазоне `2..4`, один подтвержденное человеком `expected_cluster_count` в диапазоне `2..4`, один
неизменяемый sidecar и SHA-256. Отсутствие опорной разметки не блокирует неизменяемый sidecar и SHA-256; для фрагментов также есть опорная разметка.
измерение и не ослабляет остальные gates. Для канонического Ryzen-прогона Числа старых контролей не копируются.
используются подтвержденные владельцем числа `3 / 2 / 2`; их привязывают к
источникам по хешам, а не по порядку нейтральных имен. Повторное прослушивание
для подсчета голосов не требуется.
### Шаг 3. Пройти пользовательский gate ### Шаг 3. Пройти пользовательский gate
@@ -221,10 +216,6 @@ fragment-sidecar получается детерминированным сре
Критерий завершения: в закрытом manifest есть timestamped decision `approved`. Критерий завершения: в закрытом manifest есть timestamped decision `approved`.
Без него исход `needs-user`; стенд не загружает модели. Без него исход `needs-user`; стенд не загружает модели.
В каноническом Ryzen-прогоне решением `approved` служит датированный handoff
владельца в комментарии к задаче #46. Нового подтверждения до достижения
30%-го порога не требуется.
## Измеряемая величина и A/B ## Измеряемая величина и A/B
Время W0 - wall вокруг одного `diarizer.process(samples)`. Время кандидата - Время W0 - wall вокруг одного `diarizer.process(samples)`. Время кандидата -
@@ -342,10 +333,9 @@ hashes и один pair ID; рассчитаны ускорение, CPU и RSS
- каноническая нумерация меток детерминирована на повторном расчете метрик; - каноническая нумерация меток детерминирована на повторном расчете метрик;
- все inputs, параметры и output artifacts имеют SHA-256. - все inputs, параметры и output artifacts имеют SHA-256.
Purity для фрагментов с опорной разметкой и число слов без говорящего Purity и число слов без говорящего сравниваются с новым `W0-FRESH`. Ухудшение
сравниваются с новым `W0-FRESH`. Ухудшение не дает автоматического `fail`, но не дает автоматического `fail`, но обязательно добавляет все затронутые места в
обязательно добавляет все затронутые места в слуховой gate. Старые абсолютные слуховой gate. Старые абсолютные значения не участвуют.
значения не участвуют.
Открытый диагностикой слуховой gate не прерывает шаги 5-6. Стенд накапливает Открытый диагностикой слуховой gate не прерывает шаги 5-6. Стенд накапливает
затронутые места и добавляет их в материал шага 7. Состояние `needs-user` из-за затронутые места и добавляет их в материал шага 7. Состояние `needs-user` из-за
@@ -363,7 +353,7 @@ Purity для фрагментов с опорной разметкой и чи
- каждая итоговая speaker label принадлежит `1..4`; - каждая итоговая speaker label принадлежит `1..4`;
- число и суммарная длительность интервалов по каждому каналу; - число и суммарная длительность интервалов по каждому каналу;
- число коротких интервалов, overlap ratio, unmatched words, малые/остаточные - число коротких интервалов, overlap ratio, unmatched words, малые/остаточные
кластеры и mapped purity для фрагментов с опорной разметкой; кластеры и mapped purity относительно свежей опорной разметки;
- wall отдельно для model inference и segment post-processing. - wall отдельно для model inference и segment post-processing.
Любой неверный shape/value/label или эффективная конфигурация, отличная от Любой неверный shape/value/label или эффективная конфигурация, отличная от
@@ -390,17 +380,17 @@ RSS выше 2 ГиБ на машине с 16 ГиБ требует отдель
## Артефакты и приватность ## Артефакты и приватность
Checkout и дерево сборки NeMo-Speech.cpp, скачанные веса и новое закрытое Checkout и дерево сборки NeMo-Speech.cpp, скачанные веса и новое закрытое
хранилище размещаются вне рабочей копии local-transcriber. `.scratch` внутри хранилище размещаются вне рабочей копии local-transcriber. Обычная `.scratch`
репозитория целиком игнорируется, но остается локальной частью конкретного внутри репозитория не подходит: `.gitignore` покрывает там только
checkout и не подходит для закрытого переносимого хранилища. Ни один `.scratch/handoffs/`. Ни один runtime/model/build artifact или закрытый вход не
runtime/model/build artifact или закрытый вход не попадает в Git. попадает в Git.
Новое закрытое хранилище содержит: Новое закрытое хранилище содержит:
- versioned manifest со schema, clean local-transcriber source commit, - versioned manifest со schema, clean local-transcriber source commit,
source/model/runtime/build hashes и расписанием; source/model/runtime/build hashes и расписанием;
- исходные ссылки, подготовленные WAV и recipe преобразования; - исходные ссылки, подготовленные WAV и recipe преобразования;
- ASR-sidecar и имеющуюся опорную разметку; - ASR-sidecar и опорную разметку;
- raw per-fragment results, intervals, word assignments и probability - raw per-fragment results, intervals, word assignments и probability
diagnostics; diagnostics;
- machine/power/runtime guards и stderr/stdout logs; - machine/power/runtime guards и stderr/stdout logs;
@@ -468,9 +458,8 @@ privacy scan. Реализация стенда фиксируется чист
## Критерии завершения эксперимента ## Критерии завершения эксперимента
- [ ] Свежая опора собрана в новом закрытом хранилище; подтвержденные входы, - [ ] Свежая опора подтверждена владельцем и не наследует старые контрольные
доступная разметка и числа голосов связаны по хешам, а старые manifest, значения.
расписание и результаты не наследуются.
- [ ] Runtime, модель, сборка и оба рецепта закреплены и проверены до моделей. - [ ] Runtime, модель, сборка и оба рецепта закреплены и проверены до моделей.
- [ ] Clean local-transcriber source commit сохранен в закрытом хранилище и - [ ] Clean local-transcriber source commit сохранен в закрытом хранилище и
публичном отчете, а `--validate-only` проверяет его до моделей. публичном отчете, а `--validate-only` проверяет его до моделей.
@@ -1,167 +0,0 @@
# Владение выполнением транскрипции
**Статус:** реализовано. Правила выбора исполнения и 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 и качеством диаризации сюда не входит.
+1 -8
View File
@@ -8,23 +8,16 @@ 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"
+7 -28
View File
@@ -1,14 +1,12 @@
""" """
Подготовка CUDA-библиотек из extra перед созданием CUDA-модели. Preload CUDA-библиотек из pip-пакетов до импорта ctranslate2.
Проблема: ctranslate2 на Linux делает dlopen("libcublas.so.12"), Проблема: ctranslate2 на Linux делает dlopen("libcublas.so.12"),
но не знает, что библиотека лежит внутри pip-пакета nvidia-cublas-cu12. но не знает, что библиотека лежит внутри pip-пакета nvidia-cublas-cu12.
На Windows ctranslate2 не добавляет nvidia/cublas/bin в поиск DLL. На Windows ctranslate2 решает это сам через os.add_dll_directory.
Регистрируем каталог и загружаем cuBLAS Lt, затем cuBLAS по полному пути.
Храним DLL и регистрацию каталога до завершения процесса.
Решение: загружаем libcublas.so.12 по полному пути через ctypes.CDLL Решение: загружаем libcublas.so.12 по полному пути через ctypes.CDLL
с флагом RTLD_GLOBAL до создания CUDA-модели. Динамический линкер с флагом RTLD_GLOBAL до первого import ctranslate2. Динамический линкер
кеширует загруженные библиотеки по soname — когда ctranslate2 потом кеширует загруженные библиотеки по soname — когда ctranslate2 потом
вызовет dlopen("libcublas.so.12"), линкер вернёт уже загруженный handle. вызовет dlopen("libcublas.so.12"), линкер вернёт уже загруженный handle.
@@ -23,24 +21,20 @@ 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:
"""Загружает cuBLAS из nvidia-cublas-cu12 в адресное пространство процесса. """Загружает libcublas из nvidia-cublas-cu12 в адресное пространство процесса.
Вызывать перед созданием CUDA-модели; CPU-путь не требует bootstrap. Вызывать ДО первого import ctranslate2.
Безопасно вызывать многократно и на платформах без nvidia-cublas-cu12. Безопасно вызывать многократно и на платформах без nvidia-cublas-cu12.
""" """
if sys.platform not in ("linux", "win32"): if sys.platform != "linux":
return return
try: try:
import nvidia.cublas # type: ignore[import-untyped] import nvidia.cublas # type: ignore[import-untyped]
except ImportError: except ImportError:
# Optional-пакет не установлен; системная cuBLAS всё ещё может работать. # nvidia-cublas-cu12 не установлен (Windows, macOS, или CPU-only setup)
return return
# nvidia.cublas может быть namespace package (__file__ == None), # nvidia.cublas может быть namespace package (__file__ == None),
@@ -48,21 +42,6 @@ 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,9 +16,6 @@ class Backend(Protocol):
наследование не требуется. наследование не требуется.
""" """
engine: str
"""Имя движка для сведений о выполнении."""
@property @property
def word_timestamps_available(self) -> bool: def word_timestamps_available(self) -> bool:
"""Гарантирует ли выбранный backend/model пословные таймкоды.""" """Гарантирует ли выбранный backend/model пословные таймкоды."""
@@ -56,7 +53,3 @@ class Backend(Protocol):
) -> TranscribeResult: ) -> TranscribeResult:
"""Транскрибирует файл, возвращает результат.""" """Транскрибирует файл, возвращает результат."""
... ...
def runtime_info(self) -> dict[str, str]:
"""Версии runtime и фактическая конфигурация для удалённой диагностики."""
...
@@ -6,17 +6,21 @@ from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from huggingface_hub import snapshot_download # CUDA bootstrap — должен быть ДО импорта faster_whisper / ctranslate2
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",
@@ -44,12 +48,10 @@ 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,
@@ -92,12 +94,6 @@ 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,
@@ -161,22 +157,6 @@ 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:
+2 -46
View File
@@ -15,7 +15,6 @@ from local_transcriber.types import (
Word, Word,
WordTimestampsUnavailableError, WordTimestampsUnavailableError,
) )
from local_transcriber.utils import package_version
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -132,8 +131,6 @@ 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
@@ -141,9 +138,6 @@ 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:
@@ -197,49 +191,20 @@ 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", **session_kwargs) vad = onnx_asr.load_vad("silero")
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,
@@ -313,7 +278,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-бэкендом. --device auto всегда выбирает ONNX CPU; " "ONNX-бэкендом. Без CUDA --device auto выбирает ONNX; "
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."
@@ -348,15 +313,6 @@ 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,7 +19,6 @@ 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] = {
@@ -57,7 +56,6 @@ 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__(
@@ -205,18 +203,6 @@ 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.
+198 -112
View File
@@ -11,11 +11,7 @@ import typer
from rich.console import Console from rich.console import Console
from rich.status import Status from rich.status import Status
from .config import ( from .config import apply_device_defaults, load_config, resolve_defaults
CliValues,
load_config,
resolve_defaults,
)
from .context_menu import install_menu as install_context_menu from .context_menu import install_menu as install_context_menu
from .context_menu import uninstall_menu as uninstall_context_menu from .context_menu import uninstall_menu as uninstall_context_menu
from .diarization import build_speaker_transcript from .diarization import build_speaker_transcript
@@ -37,12 +33,11 @@ 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,
cuda_error_hint, _is_cuda_error,
_transcribe_file,
load_model,
) )
from .types import ( from .types import (
UNKNOWN_LANGUAGE, UNKNOWN_LANGUAGE,
@@ -52,7 +47,10 @@ 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,
) )
@@ -61,13 +59,6 @@ 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,
@@ -91,6 +82,21 @@ 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":
@@ -255,9 +261,9 @@ def main(
min=0, min=0,
help="Потоки CPU (0 = дефолт библиотеки; рекомендуется = число физ. ядер)", help="Потоки CPU (0 = дефолт библиотеки; рекомендуется = число физ. ядер)",
), ),
diarize: bool | None = typer.Option( diarize: bool = typer.Option(
None, False,
"--diarize/--no-diarize", "--diarize",
help="Разделить транскрипт на реплики говорящих", help="Разделить транскрипт на реплики говорящих",
), ),
speakers: int | None = typer.Option( speakers: int | None = typer.Option(
@@ -322,36 +328,20 @@ def main(
) )
raise SystemExit(2) raise SystemExit(2)
if diarize is False and speakers is not None:
console.print(
"--no-diarize и --speakers несовместимы.",
style="red bold",
)
raise SystemExit(2)
requested_device: str | None = None
try: try:
config = load_config() config = load_config()
cli_values: CliValues = { cli_values = {
"model": model, "model": model,
"language": language, "language": language,
"device": device, "device": device,
"compute_type": compute_type, "compute_type": compute_type,
"diarize": diarize,
} }
defaults = resolve_defaults(cli_values, config) defaults = resolve_defaults(cli_values, config)
diarize_enabled = defaults["diarize"] is True or speakers is not None
requested_device = defaults["device"]
# Умолчания model/compute_type зависят от устройства — их разрешает module. resolved_device = detect_device(defaults["device"])
request = ExecutionRequest( defaults = apply_device_defaults(defaults, resolved_device, cli_values, config)
device=requested_device,
model=defaults["model"], ct_explicit = compute_type is not None or "compute_type" in config
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:
@@ -368,19 +358,23 @@ def main(
if is_batch: if is_batch:
_run_batch( _run_batch(
expanded, expanded,
request, defaults,
verbose, verbose,
force, force,
diarize=diarize_enabled, ct_explicit,
cpu_threads=threads,
diarize=diarize or speakers is not None,
speakers=speakers, speakers=speakers,
) )
else: else:
_run_single( _run_single(
expanded[0], expanded[0],
request, defaults,
output, output,
verbose, verbose,
diarize=diarize_enabled, ct_explicit,
cpu_threads=threads,
diarize=diarize or speakers is not None,
speakers=speakers, speakers=speakers,
) )
except KeyboardInterrupt: except KeyboardInterrupt:
@@ -389,15 +383,21 @@ 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:
_print_cuda_hint(exc, requested_device) if _is_cuda_error(exc) and sys.platform == "win32":
console.print(
"GPU на Windows требует CUDA toolkit (включает cuBLAS).\n"
"Установите одним из способов:\n"
" choco install cuda\n"
" winget install -e --id Nvidia.CUDA\n"
"После установки перезапустите терминал.",
style="yellow",
)
if verbose: if verbose:
console.print_exception() console.print_exception()
else: else:
@@ -406,50 +406,13 @@ 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,
request: ExecutionRequest, defaults: dict[str, str],
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:
@@ -457,6 +420,9 @@ 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]")
@@ -464,18 +430,59 @@ 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()}")
transcriber = Transcriber(request) model_obj, actual_device, backend, model_path = load_model(
info = transcriber.prepare(on_status=lambda msg: console.print(msg)) defaults["model"],
_print_execution_header(info, verbose) resolved_device,
speaker_diarizer = _load_diarizer(diarize, speakers, request.cpu_threads) defaults["compute_type"],
on_status=lambda msg: console.print(msg),
with Status("Подготавливаю запуск...", console=console) as status: strict_device=strict,
result = transcriber.transcribe( compute_type_explicit=compute_type_explicit,
validated_file, cpu_threads=cpu_threads,
on_segment=on_segment if verbose else None, )
on_status=status.update, 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:
tfr = _transcribe_file(
model=model_obj,
actual_device=actual_device,
backend=backend,
model_path=model_path,
file_path=validated_file,
model_name=defaults["model"],
compute_type=defaults["compute_type"],
language=defaults["language"] if defaults["language"] != "auto" else None,
on_segment=on_segment if verbose else None,
on_status=status.update,
strict_device=strict,
cpu_threads=cpu_threads,
)
result = tfr.result
speaker_transcript = None speaker_transcript = None
diarization_warning = None diarization_warning = None
diarization_degraded = False diarization_degraded = False
@@ -502,19 +509,33 @@ 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")
language_mode = _format_language_mode(request.language or "auto", result) device_info = _format_device_info(result.device_used)
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=info.model, model_name=defaults["model"],
device_info=info.description, device_info=device_info,
language_mode=language_mode, language_mode=language_mode,
speaker_transcript=speaker_transcript, speaker_transcript=speaker_transcript,
diarization_warning=diarization_warning, diarization_warning=diarization_warning,
@@ -531,9 +552,11 @@ def _run_single(
def _run_batch( def _run_batch(
files: list[Path], files: list[Path],
request: ExecutionRequest, defaults: dict[str, str],
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:
@@ -563,11 +586,50 @@ def _run_batch(
raise SystemExit(1) raise SystemExit(1)
return return
# Phase 2: module выполнения создаётся, когда есть работа # Phase 2: Load model (ensure + create в одном вызове)
transcriber = Transcriber(request) requested_device = defaults["device"]
info = transcriber.prepare(on_status=lambda msg: console.print(msg)) resolved_device = detect_device(requested_device)
_print_execution_header(info, verbose) strict = requested_device != "auto"
speaker_diarizer = _load_diarizer(diarize, speakers, request.cpu_threads) model_obj, actual_device, backend, model_path = load_model(
defaults["model"],
resolved_device,
defaults["compute_type"],
on_status=lambda msg: console.print(msg),
strict_device=strict,
compute_type_explicit=compute_type_explicit,
cpu_threads=cpu_threads,
)
if actual_device == "openvino-gpu" and defaults["model"] != "large-v3":
console.print(
"Совет: --model large-v3 даёт лучшее качество на GPU (~2x дольше)",
style="dim",
)
if actual_device != resolved_device:
if requested_device == "auto":
console.print(
f"Определено устройство {resolved_device}, "
f"но используется {actual_device} (fallback)",
style="yellow",
)
else:
console.print(
f"Запрошено {requested_device}, используется {actual_device}",
style="yellow",
)
speaker_diarizer = None
if diarize:
if not backend.word_timestamps_available:
raise ValueError(
"Выбранный движок или модель не поддерживает пословные таймкоды"
)
speaker_diarizer = load_speaker_diarizer(
speakers=speakers,
threads=cpu_threads,
on_status=lambda message: console.print(message),
)
# Phase 3: Transcribe # Phase 3: Transcribe
processed = 0 processed = 0
@@ -585,15 +647,38 @@ 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:
result = transcriber.transcribe( tfr = _transcribe_file(
file, model=model_obj,
actual_device=actual_device,
backend=backend,
model_path=model_path,
file_path=file,
model_name=defaults["model"],
compute_type=defaults["compute_type"],
language=defaults["language"]
if defaults["language"] != "auto"
else None,
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,
) )
language_mode = _format_language_mode(request.language or "auto", result) if tfr.actual_device != actual_device:
console.print(
f" {file.name}: fallback на {tfr.actual_device} при транскрипции",
style="yellow",
)
# Обновляем после возможного mid-stream fallback
model_obj = tfr.model
actual_device = tfr.actual_device
backend = tfr.backend
model_path = tfr.model_path
result = tfr.result
language_mode = _format_language_mode(defaults["language"], result)
speaker_transcript = None speaker_transcript = None
diarization_warning = None diarization_warning = None
file_degraded = False file_degraded = False
@@ -633,11 +718,13 @@ 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=info.model, model_name=defaults["model"],
device_info=info.description, device_info=device_info,
language_mode=language_mode, language_mode=language_mode,
speaker_transcript=speaker_transcript, speaker_transcript=speaker_transcript,
diarization_warning=diarization_warning, diarization_warning=diarization_warning,
@@ -656,7 +743,6 @@ 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:
+36 -87
View File
@@ -3,56 +3,14 @@
import tomllib import tomllib
import warnings import warnings
from pathlib import Path from pathlib import Path
from typing import TypedDict, TypeVar, cast
type ConfigValue = str | bool HARDCODED_DEFAULTS: dict[str, str] = {
_T = TypeVar("_T")
class ConfigValues(TypedDict, total=False):
"""Проверенные значения из TOML-конфига."""
model: str
language: str
device: str
compute_type: str
diarize: bool
class CliValues(TypedDict, total=False):
"""Явные значения CLI до разрешения каскада."""
model: str | None
language: str | None
device: str | None
compute_type: str | None
diarize: bool | None
class ResolvedConfig(TypedDict):
"""Конфигурация после каскада CLI > TOML.
``model`` и ``compute_type`` остаются ``None``, если не заданы явно:
их умолчания зависят от устройства и разрешаются при загрузке модели.
"""
model: str | None
language: str
device: str
compute_type: str | None
diarize: bool
HARDCODED_DEFAULTS: ResolvedConfig = {
"model": "medium", "model": "medium",
"language": "ru", "language": "ru",
"device": "auto", "device": "auto",
"compute_type": "float32", "compute_type": "float32",
"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"},
@@ -88,7 +46,7 @@ def find_config_file() -> Path | None:
return None return None
def load_config(path: Path | None = None) -> ConfigValues: def load_config(path: Path | None = None) -> dict[str, str]:
"""Загружает и валидирует TOML-конфиг. """Загружает и валидирует TOML-конфиг.
Неизвестные ключи вызывают предупреждение (а не ошибку) для forward Неизвестные ключи вызывают предупреждение (а не ошибку) для forward
@@ -112,19 +70,11 @@ def load_config(path: Path | None = None) -> ConfigValues:
stacklevel=2, stacklevel=2,
) )
result: dict[str, ConfigValue] = {} result: dict[str, str] = {}
for key in _VALID_KEYS: for key in _VALID_KEYS:
if key not in data: if key not in data:
continue continue
value = data[key] value = data[key]
if key == "diarize":
if not isinstance(value, bool):
raise ValueError(
f"Значение 'diarize' в {path} должно быть логическим, "
f"получено {type(value).__name__}"
)
result[key] = value
continue
if not isinstance(value, str): if not isinstance(value, str):
raise ValueError( raise ValueError(
f"Значение '{key}' в {path} должно быть строкой, получено {type(value).__name__}" f"Значение '{key}' в {path} должно быть строкой, получено {type(value).__name__}"
@@ -138,41 +88,40 @@ def load_config(path: Path | None = None) -> ConfigValues:
raise ValueError(f"Значение 'language' в {path} не может быть пустым") raise ValueError(f"Значение 'language' в {path} не может быть пустым")
result[key] = value result[key] = value
return cast(ConfigValues, result) return result
def resolve_defaults(cli_values: CliValues, config: ConfigValues) -> ResolvedConfig: def resolve_defaults(
"""Каскад приоритетов: CLI > конфиг-файл > hardcoded-дефолты. cli_values: dict[str, str | None], config: dict[str, str]
) -> dict[str, str]:
Device-aware ключи (model, compute_type) без явного значения остаются None. """Каскад приоритетов: CLI > конфиг-файл > hardcoded-дефолты."""
""" result: dict[str, str] = {}
return { for key in HARDCODED_DEFAULTS:
"model": _resolve_value(cli_values.get("model"), config.get("model"), None), cli_val = cli_values.get(key)
"language": _resolve_value( if cli_val is not None:
cli_values.get("language"), result[key] = cli_val
config.get("language"), elif key in config:
HARDCODED_DEFAULTS["language"], result[key] = config[key]
), else:
"device": _resolve_value( result[key] = HARDCODED_DEFAULTS[key]
cli_values.get("device"), return result
config.get("device"),
HARDCODED_DEFAULTS["device"],
),
"compute_type": _resolve_value(
cli_values.get("compute_type"), config.get("compute_type"), None
),
"diarize": _resolve_value(
cli_values.get("diarize"),
config.get("diarize"),
HARDCODED_DEFAULTS["diarize"],
),
}
def _resolve_value(cli_value: _T | None, config_value: _T | None, default: _T) -> _T: def apply_device_defaults(
"""Выбирает первое явно заданное значение каскада.""" defaults: dict[str, str],
if cli_value is not None: resolved_device: str,
return cli_value cli_values: dict[str, str | None],
if config_value is not None: config: dict[str, str],
return config_value ) -> dict[str, str]:
return default """Применяет 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 result
@@ -316,7 +316,7 @@ def _resolve(root: Path, value: Any) -> Path:
if not isinstance(value, str) or not value: if not isinstance(value, str) or not value:
raise PreflightError("unsafe-relative-path") raise PreflightError("unsafe-relative-path")
pure = PurePosixPath(value) pure = PurePosixPath(value)
if pure.is_absolute() or ".." in pure.parts or "\\" in value or "\x00" in value: if pure.is_absolute() or ".." in pure.parts or "\\" in value:
raise PreflightError("path-escape") raise PreflightError("path-escape")
base = root.resolve() base = root.resolve()
resolved = (base / Path(value)).resolve() resolved = (base / Path(value)).resolve()
@@ -509,22 +509,17 @@ def validate_manifest(
media_hash = _sha(record["sha256"], "record-hash") media_hash = _sha(record["sha256"], "record-hash")
sidecar_hash = _sha(record["sidecar_sha256"], "sidecar-hash") sidecar_hash = _sha(record["sidecar_sha256"], "sidecar-hash")
if role == "fragment": if role == "fragment":
reference_path = record["reference_path"] _sha(record["reference_sha256"], "reference-hash")
reference_hash = record["reference_sha256"]
if (reference_path is None) != (reference_hash is None):
raise PreflightError("reference-binding")
if reference_hash is not None:
_sha(reference_hash, "reference-hash")
fragment_hashes.append(media_hash) fragment_hashes.append(media_hash)
fragment_sidecars.append(sidecar_hash) fragment_sidecars.append(sidecar_hash)
if verify_files: if verify_files:
_verify(root, record["path"], media_hash, record["size_bytes"]) _verify(root, record["path"], media_hash, record["size_bytes"])
_verify(root, record["sidecar_path"], sidecar_hash, None) _verify(root, record["sidecar_path"], sidecar_hash, None)
if role == "fragment" and reference_hash is not None: if role == "fragment":
_verify( _verify(
root, root,
reference_path, record["reference_path"],
reference_hash, record["reference_sha256"],
None, None,
) )
_validate_schedule(value["schedule"], fragment_hashes, fragment_sidecars) _validate_schedule(value["schedule"], fragment_hashes, fragment_sidecars)
@@ -1095,6 +1090,8 @@ def memory_gate(
for value in integer_values for value in integer_values
): ):
raise ValueError("memory-value") raise ValueError("memory-value")
if swap_before_bytes > 0 or swap_after_bytes > 0:
return {"state": "stop", "reason": "active-swap", **base}
if stage == "fragment": if stage == "fragment":
if len(rss_after_inputs) != 3: if len(rss_after_inputs) != 3:
return {"state": "stop", "reason": "rss-no-plateau", **base} return {"state": "stop", "reason": "rss-no-plateau", **base}
@@ -1444,10 +1441,13 @@ def _guard_transition_reasons(
def _swap_active(before: Mapping[str, Any], after: Mapping[str, Any] | None = None) -> bool: def _swap_active(before: Mapping[str, Any], after: Mapping[str, Any] | None = None) -> bool:
if before["swap_used_bytes"] > 0:
return True
if after is None: if after is None:
return False return False
return bool( return bool(
after["swap_sin_bytes"] != before["swap_sin_bytes"] after["swap_used_bytes"] > 0
or after["swap_sin_bytes"] != before["swap_sin_bytes"]
or after["swap_sout_bytes"] != before["swap_sout_bytes"] or after["swap_sout_bytes"] != before["swap_sout_bytes"]
) )
+126 -248
View File
@@ -2,245 +2,170 @@
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 ( from local_transcriber.types import ( # noqa: F401
Segment, Segment,
TranscribeFileResult,
TranscribeResult, TranscribeResult,
WordTimestampsUnavailableError, WordTimestampsUnavailableError,
) )
from local_transcriber.utils import detect_device, get_gpu_name, get_intel_gpu_name
@dataclass(frozen=True) def load_model(
class ExecutionRequest: model_name: str,
"""Запрошенные настройки запуска до разрешения умолчаний. device: str,
compute_type: str,
on_status: Callable[[str], None] | None = None,
strict_device: bool = False,
compute_type_explicit: bool = False,
cpu_threads: int = 0,
) -> tuple[Any, str, Any, str]:
"""Загружает модель: ensure + create с fallback.
``None`` в model/compute_type означает device-aware умолчание; Возвращает (model, actual_device, backend, model_path).
``strict_device=None`` — strict для любого явного device, кроме ``auto``. compute_type_explicit: True если пользователь явно указал --compute-type.
cpu_threads: число потоков для CPU inference (0 = дефолт библиотеки).
""" """
backend = get_backend(device, compute_type_explicit=compute_type_explicit)
actual_device = device
device: str = "auto" model_path = backend.ensure_model_available(model_name, compute_type, on_status)
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:
@dataclass(frozen=True) _notify_status(on_status, f"Инициализирую модель на {device}...")
class ExecutionInfo: model = backend.create_model(
"""Сведения о выполнении: что запрошено, что выбрано и чем выполняется.""" 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"
) )
self._compute_type_explicit = request.compute_type is not None # Резолвим actual_device по реальному OpenVINO device
self._resolved_device = detect_device(request.device) ov_dev = getattr(backend, "actual_ov_device", None)
self._device = self._resolved_device if ov_dev == "GPU" and actual_device != "openvino-gpu":
self._backend: Any = None actual_device = "openvino-gpu"
self._model: Any = None elif (
self._info: ExecutionInfo | None = None ov_dev == "CPU"
and actual_device.startswith("openvino")
@property and actual_device != "openvino-cpu"
def execution(self) -> ExecutionInfo: ):
"""Текущие сведения о выполнении; доступны после prepare().""" actual_device = "openvino-cpu"
if self._info is None: except (RuntimeError, ValueError) as exc:
raise RuntimeError("Модель ещё не подготовлена: вызовите prepare()") if device != "cpu" and _is_backend_error(exc, device):
return self._info if strict_device:
def prepare(self, on_status: Callable[[str], None] | None = None) -> ExecutionInfo:
"""Загружает модель и проверяет возможности до первого файла. Идемпотентно."""
if self._info is not None:
return self._info
try:
info = self._load(self._device, on_status)
except (RuntimeError, ValueError) as exc:
if not self._may_fall_back(exc):
raise raise
warnings.warn( warnings.warn(
f"Не удалось загрузить модель на {self._device}: {exc}. " f"Не удалось загрузить модель на {device}: {exc}. Переключение на CPU.",
"Переключение на CPU.",
stacklevel=2, stacklevel=2,
) )
info = self._load("cpu", on_status) actual_device = "cpu"
if self._request.require_word_timestamps and not info.word_timestamps_available: backend = get_backend("cpu")
raise ValueError( model_path = backend.ensure_model_available(
"Выбранный движок или модель не поддерживает пословные таймкоды" model_name, compute_type, on_status
) )
self._info = info _notify_status(on_status, "Инициализирую модель на cpu...")
return info model = backend.create_model(
model_path, "cpu", compute_type, cpu_threads=cpu_threads
)
else:
raise
def transcribe( return model, actual_device, backend, model_path
self,
file_path: Path,
on_segment: Callable[[Segment], None] | None = None, def _transcribe_file(
on_status: Callable[[str], None] | None = None, model: Any,
) -> TranscribeResult: actual_device: str,
"""Транскрибирует один файл; при разрешённом fallback перезагружает модель.""" backend: Any,
self.prepare(on_status) model_path: str,
lang_arg = self._request.language or None file_path: Path,
if lang_arg == "auto": model_name: str,
lang_arg = None compute_type: str,
try: language: str | None = None,
result = self._recognize(file_path, lang_arg, on_segment, on_status) on_segment: Callable[[Segment], None] | None = None,
except (RuntimeError, ValueError) as exc: on_status: Callable[[str], None] | None = None,
if not self._may_fall_back(exc): strict_device: bool = False,
cpu_threads: int = 0,
) -> TranscribeFileResult:
"""Транскрибирует один файл. При mid-stream fallback перезагружает модель."""
lang_arg = language if language and language != "auto" else None
try:
_notify_status(on_status, "Транскрибирую...")
result = backend.transcribe(model, file_path, lang_arg, on_segment, on_status)
result.device_used = actual_device
except (RuntimeError, ValueError) as exc:
if actual_device != "cpu" and _is_backend_error(exc, actual_device):
if strict_device:
raise raise
warnings.warn( warnings.warn(
f"Ошибка при транскрипции на {self._device}: {exc}. " f"Ошибка при транскрипции на {actual_device}: {exc}. "
"Переключение на CPU и повтор.", "Переключение на CPU и повтор.",
stacklevel=2, stacklevel=2,
) )
self._info = self._load("cpu", on_status) actual_device = "cpu"
result = self._recognize(file_path, lang_arg, on_segment, on_status) backend = get_backend("cpu")
return result model_path = backend.ensure_model_available(
model_name, compute_type, on_status
)
_notify_status(on_status, "Инициализирую модель на cpu...")
model = backend.create_model(
model_path, "cpu", compute_type, cpu_threads=cpu_threads
)
_notify_status(on_status, "Транскрибирую...")
result = backend.transcribe(
model, file_path, lang_arg, on_segment, on_status
)
result.device_used = actual_device
else:
raise
def _recognize( return TranscribeFileResult(
self, result=result,
file_path: Path, model=model,
language: str | None, actual_device=actual_device,
on_segment: Callable[[Segment], None] | None, backend=backend,
on_status: Callable[[str], None] | None, model_path=model_path,
) -> 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 | None = None, model_name: str = "large-v3",
device: str = "auto", device: str = "auto",
compute_type: str | None = None, compute_type: str = "int8",
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:
"""Публичный путь одного файла через тот же module выполнения, что и CLI. """High-level API: загрузка модели + транскрипция за один вызов."""
model, actual_device, backend, model_path = load_model(
``None`` в model_name/compute_type берёт умолчания выбранного исполнения. model_name,
""" device,
run = Transcriber( compute_type,
ExecutionRequest( on_status,
device=device, strict_device,
model=model_name, compute_type_explicit=True, # Python API — caller explicitly chose compute_type
compute_type=compute_type, cpu_threads=cpu_threads,
language=language,
cpu_threads=cpu_threads,
strict_device=strict_device,
)
) )
return run.transcribe(file_path, on_segment, on_status) 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
def ensure_model_available( def ensure_model_available(
@@ -250,6 +175,8 @@ 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(
@@ -263,60 +190,11 @@ def ensure_model_available(
def _is_cuda_error(exc: BaseException) -> bool: def _is_cuda_error(exc: BaseException) -> bool:
"""Проверяет, относится ли ошибка к CUDA-бэкенду.""" """Проверка CUDA ошибок — используется в cli.py для Windows-диагностики."""
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,6 +2,7 @@
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"
@@ -82,5 +83,14 @@ 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
+5 -11
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,13 +16,15 @@ SUPPORTED_EXTENSIONS = {
def detect_device(requested: str = "auto") -> str: def detect_device(requested: str = "auto") -> str:
"""Определяет устройство для вычислений. """Определяет устройство для вычислений.
При ``requested="auto"`` всегда выбирает ONNX на CPU. При ``requested="auto"`` выбирает CUDA при наличии ``nvidia-smi``,
CUDA включается только явным выбором пользователя. иначе ONNX на CPU.
``"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():
@@ -142,11 +144,3 @@ 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,25 +7,6 @@ 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=" Привет")
@@ -83,18 +64,3 @@ 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,21 +463,3 @@ 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"]
+872 -406
View File
File diff suppressed because it is too large. Load diff
+74 -34
View File
@@ -1,8 +1,10 @@
from pathlib import Path
from unittest.mock import patch 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,
@@ -39,23 +41,6 @@ def test_load_config_valid(tmp_path):
assert result == {"model": "small", "language": "ru"} assert result == {"model": "small", "language": "ru"}
@pytest.mark.parametrize("value", [True, False])
def test_load_config_accepts_diarize_boolean(tmp_path, value):
config = tmp_path / "config.toml"
config.write_text(f"diarize = {str(value).lower()}\n")
assert load_config(config) == {"diarize": value}
@pytest.mark.parametrize("toml_value", ['"true"', "1"])
def test_load_config_rejects_non_boolean_diarize(tmp_path, toml_value):
config = tmp_path / "config.toml"
config.write_text(f"diarize = {toml_value}\n")
with pytest.raises(ValueError, match="должно быть логическим"):
load_config(config)
def test_load_config_malformed(tmp_path): def test_load_config_malformed(tmp_path):
config = tmp_path / "config.toml" config = tmp_path / "config.toml"
config.write_text("this is not valid toml [[[") config.write_text("this is not valid toml [[[")
@@ -106,33 +91,64 @@ def test_resolve_defaults_config_wins():
assert result["model"] == "tiny" assert result["model"] == "tiny"
def test_resolve_defaults_leaves_device_aware_keys_empty(): def test_resolve_defaults_hardcoded_fallback():
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": None, "model": "medium",
"language": "ru", "language": "ru",
"device": "auto", "device": "auto",
"compute_type": None, "compute_type": "float32",
"diarize": False,
} }
@pytest.mark.parametrize( def test_apply_device_defaults_cuda():
("cli_value", "config_value", "expected"), defaults = {"model": "medium", "language": "ru", "device": "auto", "compute_type": "float32"}
[ cli = {"model": None, "language": None, "device": None, "compute_type": None}
(None, True, True), result = apply_device_defaults(defaults, "cuda", cli, {})
(None, False, False), assert result["model"] == "medium"
(True, False, True), assert result["compute_type"] == "float16"
(False, True, False),
],
)
def test_resolve_defaults_diarize_priority(cli_value, config_value, expected):
cli = {"diarize": cli_value}
config = {"diarize": config_value}
assert resolve_defaults(cli, config)["diarize"] is expected
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):
@@ -154,3 +170,27 @@ 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"
+3 -64
View File
@@ -48,73 +48,12 @@ 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_macos(monkeypatch): def test_ensure_cublas_skips_non_linux(monkeypatch):
"""На macOS bootstrap не загружает CUDA.""" """На не-Linux платформах -- no-op."""
monkeypatch.setattr(sys, "platform", "darwin") monkeypatch.setattr(sys, "platform", "win32")
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:
+3 -78
View File
@@ -1,7 +1,6 @@
"""Tests for onnx-asr backend.""" """Tests for onnx-asr backend."""
import warnings import warnings
from unittest.mock import MagicMock
import pytest import pytest
@@ -65,12 +64,12 @@ class TestEnsureModelAvailable:
def with_timestamps(self): def with_timestamps(self):
return self return self
def fake_load_model(*, model, quantization, providers): def fake_load_model(*, model, quantization):
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, **kwargs: None) monkeypatch.setattr("onnx_asr.load_vad", lambda model: 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(
@@ -88,56 +87,6 @@ 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()
@@ -150,7 +99,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, **kwargs: object()) monkeypatch.setattr("onnx_asr.load_vad", lambda model: object())
model = OnnxAsrBackend().create_model("gigaam-v3-e2e-rnnt", "onnx", "int8") model = OnnxAsrBackend().create_model("gigaam-v3-e2e-rnnt", "onnx", "int8")
@@ -178,7 +127,6 @@ 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"
@@ -689,26 +637,3 @@ 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"
-1
View File
@@ -222,7 +222,6 @@ def test_load_speaker_diarizer_keeps_corrupt_cache_when_download_is_invalid(
with ( with (
patch("huggingface_hub.cached_assets_path", return_value=tmp_path), patch("huggingface_hub.cached_assets_path", return_value=tmp_path),
patch.dict(sys.modules, {"sherpa_onnx": SimpleNamespace()}),
patch("httpx.stream", return_value=FakeResponse()), patch("httpx.stream", return_value=FakeResponse()),
pytest.raises(RuntimeError, match="Контрольная сумма"), pytest.raises(RuntimeError, match="Контрольная сумма"),
): ):
+12 -119
View File
@@ -213,60 +213,6 @@ def test_preflight_validates_fresh_exact_schema_before_loading(tmp_path):
assert calls == [] assert calls == []
def test_approved_fresh_basis_loads_models_after_preflight(tmp_path):
manifest = _manifest(tmp_path)
calls = []
outcome = experiment.preflight_and_load(
manifest,
root=tmp_path,
environment_probe=_environment,
w0_loader=lambda: calls.append("w0"),
candidate_loader=lambda: calls.append("candidate"),
)
assert outcome.state == "valid"
assert calls == ["w0", "candidate"]
def test_fragment_without_reference_loads_models_after_preflight(tmp_path):
manifest = _manifest(tmp_path)
fragment = manifest["inputs"][0]["fragment"]
(tmp_path / fragment["reference_path"]).unlink()
fragment["reference_path"] = None
fragment["reference_sha256"] = None
calls = []
outcome = experiment.preflight_and_load(
manifest,
root=tmp_path,
environment_probe=_environment,
w0_loader=lambda: calls.append("w0"),
candidate_loader=lambda: calls.append("candidate"),
)
assert outcome.state == "valid"
assert calls == ["w0", "candidate"]
@pytest.mark.parametrize("field", ["reference_path", "reference_sha256"])
def test_fragment_rejects_half_present_reference_before_loaders(tmp_path, field):
manifest = _manifest(tmp_path)
manifest["inputs"][0]["fragment"][field] = None
calls = []
with pytest.raises(experiment.PreflightError, match="reference-binding"):
experiment.preflight_and_load(
manifest,
root=tmp_path,
environment_probe=_environment,
w0_loader=lambda: calls.append("w0"),
candidate_loader=lambda: calls.append("candidate"),
)
assert calls == []
def test_missing_owner_approval_is_needs_user_and_never_loads(tmp_path): def test_missing_owner_approval_is_needs_user_and_never_loads(tmp_path):
manifest = _manifest(tmp_path) manifest = _manifest(tmp_path)
manifest["fresh_basis"]["approval"] = None manifest["fresh_basis"]["approval"] = None
@@ -345,25 +291,6 @@ def test_preflight_rejects_hash_path_and_symlink_escape_without_leaking_path(tmp
experiment.validate_manifest(manifest, root=tmp_path, environment=_environment()) experiment.validate_manifest(manifest, root=tmp_path, environment=_environment())
def test_preflight_rejects_nul_path_without_leaking_or_loading(tmp_path):
manifest = _manifest(tmp_path)
manifest["inputs"][0]["source_path"] = "private\x00source.wav"
calls = []
with pytest.raises(experiment.PreflightError) as raised:
experiment.preflight_and_load(
manifest,
root=tmp_path,
environment_probe=_environment,
w0_loader=lambda: calls.append("w0"),
candidate_loader=lambda: calls.append("candidate"),
)
assert str(raised.value) == "path-escape"
assert "private" not in str(raised.value)
assert calls == []
def test_preflight_rejects_legacy_cell_names_roots_and_recipe_pin_mismatch(tmp_path): def test_preflight_rejects_legacy_cell_names_roots_and_recipe_pin_mismatch(tmp_path):
manifest = _manifest(tmp_path) manifest = _manifest(tmp_path)
manifest["schedule"][0]["semantic_id"] = "41-W0" manifest["schedule"][0]["semantic_id"] = "41-W0"
@@ -774,7 +701,7 @@ def test_memory_gate_uses_frozen_plateau_and_linear_growth_policy_boundaries():
peak_rss_bytes=300 * mib, peak_rss_bytes=300 * mib,
rss_after_inputs=[100 * mib, 120 * mib, 121 * mib], rss_after_inputs=[100 * mib, 120 * mib, 121 * mib],
machine_ram_bytes=16 * 1024**3, machine_ram_bytes=16 * 1024**3,
)["reason"] is None )["reason"] == "active-swap"
assert experiment.memory_gate( assert experiment.memory_gate(
stage="fragment", stage="fragment",
oom=True, oom=True,
@@ -786,23 +713,6 @@ def test_memory_gate_uses_frozen_plateau_and_linear_growth_policy_boundaries():
)["reason"] == "oom" )["reason"] == "oom"
def test_memory_gate_allows_preoccupied_swap() -> None:
mib = 1024**2
result = experiment.memory_gate(
stage="fragment",
oom=False,
swap_before_bytes=1_730_000_000,
swap_after_bytes=1_730_000_000,
peak_rss_bytes=300 * mib,
rss_after_inputs=[100 * mib, 120 * mib, 121 * mib],
machine_ram_bytes=16 * 1024**3,
)
assert result["state"] == "pass"
assert result["reason"] is None
def test_memory_gate_marks_two_gibibyte_review_as_needs_user(): def test_memory_gate_marks_two_gibibyte_review_as_needs_user():
result = experiment.memory_gate( result = experiment.memory_gate(
stage="fragment", stage="fragment",
@@ -1101,7 +1011,7 @@ def test_guarded_cell_accepts_stable_manifest_declared_performance_mode():
assert len(result["attempts"]) == 1 assert len(result["attempts"]) == 1
def test_second_guard_invalidation_stops_with_first_reason(): def test_second_guard_invalidation_stops_with_first_reason_and_active_swap_never_retries():
throttled = iter( throttled = iter(
[ [
_guard(), _guard(),
@@ -1121,33 +1031,17 @@ def test_second_guard_invalidation_stops_with_first_reason():
assert second["reason"] == "new-throttling" assert second["reason"] == "new-throttling"
assert len(second["attempts"]) == 2 assert len(second["attempts"]) == 2
calls = []
def test_guarded_cell_allows_preoccupied_swap_when_counters_stay_stable(): swapped = experiment.run_guarded_cell(
snapshots = iter(
[
_guard(
swap_used_bytes=1_730_000_000,
swap_sin_bytes=100,
swap_sout_bytes=200,
),
_guard(
swap_used_bytes=1_730_000_000,
swap_sin_bytes=100,
swap_sout_bytes=200,
),
]
)
result = experiment.run_guarded_cell(
{"semantic_id": "safe-cell", "recipe": "W0-FRESH"}, {"semantic_id": "safe-cell", "recipe": "W0-FRESH"},
runner=lambda _request: {"wall_seconds": 1.0}, runner=lambda _request: calls.append("run") or {"wall_seconds": 1.0},
guard_reader=lambda: next(snapshots), guard_reader=lambda: _guard(swap_used_bytes=1),
stabilize=lambda: None, stabilize=lambda: calls.append("stabilize"),
expected_machine=_machine(), expected_machine=_machine(),
) )
assert swapped["state"] == "stop"
assert result["state"] == "accepted" assert swapped["reason"] == "active-swap"
assert len(result["attempts"]) == 1 assert calls == []
def test_oom_stops_without_retry_and_private_result_is_written_atomically(tmp_path): def test_oom_stops_without_retry_and_private_result_is_written_atomically(tmp_path):
@@ -1229,9 +1123,8 @@ def test_guarded_cell_does_not_retry_hard_mismatch_and_sanitizes_exceptions():
assert calls == [] assert calls == []
@pytest.mark.parametrize("counter", ["swap_sin_bytes", "swap_sout_bytes"]) def test_guarded_cell_hashes_result_rejected_by_after_run_swap():
def test_guarded_cell_hashes_result_rejected_by_after_run_swap(counter): snapshots = iter([_guard(), _guard(swap_sin_bytes=1)])
snapshots = iter([_guard(), _guard(**{counter: 1})])
raw_result = {"wall_seconds": 1.0, "private": "not-published"} raw_result = {"wall_seconds": 1.0, "private": "not-published"}
result = experiment.run_guarded_cell( result = experiment.run_guarded_cell(
{"semantic_id": "safe-cell", "recipe": "W0-FRESH"}, {"semantic_id": "safe-cell", "recipe": "W0-FRESH"},
+294 -471
View File
@@ -4,12 +4,11 @@ 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,
cuda_error_hint, _transcribe_file,
ensure_model_available, ensure_model_available,
load_model,
transcribe, transcribe,
) )
from local_transcriber.types import WordTimestampsUnavailableError from local_transcriber.types import WordTimestampsUnavailableError
@@ -17,21 +16,6 @@ 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",
@@ -232,7 +216,7 @@ def test_transcribe_reports_status_transitions(mock_get_backend):
on_status=statuses.append, on_status=statuses.append,
) )
# Статусы подготовки и распознавания идут через один callback # load_model reports init status, _transcribe_file reports transcribe status
assert any("Инициализирую модель" in s for s in statuses) assert any("Инициализирую модель" in s for s in statuses)
assert any("Транскрибирую" in s for s in statuses) assert any("Транскрибирую" in s for s in statuses)
@@ -295,6 +279,101 @@ 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) ===
@@ -397,6 +476,202 @@ 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
@@ -406,455 +681,3 @@ 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_onnx_when_nvidia_smi_available(): def test_detect_device_auto_cuda_when_nvidia_smi_available():
"""При доступном nvidia-smi auto остаётся на ONNX CPU.""" """При доступном nvidia-smi auto выбирает CUDA."""
with patch( with patch(
"shutil.which", return_value="/usr/bin/nvidia-smi" "local_transcriber.utils.shutil.which", return_value="/usr/bin/nvidia-smi"
): ):
assert detect_device("auto") == "onnx" assert detect_device("auto") == "cuda"
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("shutil.which", return_value=None): with patch("local_transcriber.utils.shutil.which", return_value=None):
assert detect_device("auto") == "onnx" assert detect_device("auto") == "onnx"
Generated
+2 -10
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,11 +300,6 @@ 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" },
@@ -316,10 +311,9 @@ 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' 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 = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=12.4,<13" },
{ 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" },
@@ -328,7 +322,6 @@ 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 = [
@@ -452,7 +445,6 @@ 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]]