Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80b864077a | ||
|
|
579cb87bc0 | ||
|
|
b302e3dbb2 | ||
|
|
f8c6a4fc7e | ||
|
|
5a7fc0d613 | ||
|
|
b1dfd9dcca | ||
|
|
650e7580e5 | ||
|
|
df204e5ae4 | ||
|
|
9307486ceb | ||
|
|
f18ad6ea2a | ||
|
|
966efc3bfe | ||
|
|
e41600510b | ||
|
|
0059e28f77 | ||
|
|
1c779be139 | ||
|
|
9cfa437b33 | ||
|
|
0c3a67c4ea | ||
|
|
3b7eb603ae | ||
|
|
5990d85a58 | ||
|
|
503f2a3732 | ||
|
|
854bf55145 | ||
|
|
53142fd341 | ||
|
|
b1cbdc3e0b | ||
|
|
7d39ab908b | ||
|
|
79dbd170ce | ||
|
|
9e04dc8c25 | ||
|
|
d9c9aefdb3 | ||
|
|
f25a546754 | ||
|
|
a3d0213cb3 | ||
|
|
b5c1da24ce | ||
|
|
82596187a4 | ||
|
|
0b5703a0e3 | ||
|
|
2c1d798eeb | ||
|
|
cb6c1d7728 | ||
|
|
749ede090c | ||
|
|
59b74f166c | ||
|
|
857267763c | ||
|
|
8ae74d2748 | ||
|
|
248f260005 | ||
|
|
413cb3e6d8 | ||
|
|
9d41b893c3 |
@@ -4,3 +4,6 @@ __pycache__/
|
||||
.mypy_cache/
|
||||
dist/
|
||||
*.pyc
|
||||
.codex
|
||||
.qwen/
|
||||
.scratch/
|
||||
@@ -0,0 +1,90 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project
|
||||
|
||||
Local audio/video transcription CLI — no cloud, no API keys. Outputs markdown with timestamps.
|
||||
|
||||
**Language conventions**: code identifiers in English; docstrings, comments, UI strings, and commit messages in Russian. Style is ruff-compatible. Commits follow [Conventional Commits](https://www.conventionalcommits.org/).
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
uv sync # install dependencies
|
||||
uv run transcribe meeting.mp4 # run CLI
|
||||
uv run pytest # run all tests
|
||||
uv run pytest tests/test_cli.py # run one test file
|
||||
uv run pytest -k test_name # run single test by name
|
||||
uv run pytest -v # verbose output
|
||||
```
|
||||
|
||||
Package manager is **uv** (not pip). Build backend is hatchling.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
CLI (cli.py)
|
||||
→ config.py cascade: CLI arg → .transcriber.toml → device-aware default → hardcoded
|
||||
→ utils.py detect_device(), validate files, expand globs (Windows workaround)
|
||||
→ context_menu.py Windows SendTo: Transcribe.cmd install/uninstall (--install-menu / --uninstall-menu)
|
||||
→ transcriber.py load_model() → get_backend(device) → ensure_model_available → create_model
|
||||
_transcribe_file() with mid-stream CUDA→CPU fallback
|
||||
→ formatter.py segments → markdown with timestamps, paragraph grouping (>2s pause or >60s)
|
||||
```
|
||||
|
||||
### Backend system (`src/local_transcriber/backends/`)
|
||||
|
||||
Three backends implement the `Backend` Protocol (structural typing, no inheritance required):
|
||||
|
||||
| Backend | Module | Devices | Library |
|
||||
|---------|--------|---------|---------|
|
||||
| FasterWhisper | `faster_whisper.py` | `cpu`, `cuda` | `faster_whisper` (CTranslate2) |
|
||||
| OpenVINO | `openvino.py` | `openvino`, `openvino-gpu`, `openvino-cpu` | `openvino_genai` |
|
||||
| OnnxAsr | `onnx_asr.py` | `onnx` | `onnx_asr` (onnxruntime) |
|
||||
|
||||
`get_backend(device)` in `backends/__init__.py` maps device string to backend with lazy imports.
|
||||
|
||||
### Key design decisions
|
||||
|
||||
- **Two-level fallback**: GPU→CPU at model load time AND mid-stream during transcription (GPU visible via nvidia-smi but insufficient VRAM).
|
||||
- **CUDA bootstrap** (`_cuda_bootstrap.py`): preloads `libcublas.so.12` via `ctypes.CDLL(RTLD_GLOBAL)` before importing ctranslate2, because pip's `nvidia-cublas-cu12` installs to a non-standard path and `LD_LIBRARY_PATH` can't be changed at runtime (glibc caches it).
|
||||
- **Batch mode**: 3-phase pipeline (prescan → load model once → transcribe all). `TranscribeFileResult` carries updated model/backend/device state between files.
|
||||
- **Device-aware defaults**: `compute_type` and `model` vary by device (float16 for CUDA, int8 for OpenVINO, float32 for CPU). Defined in `config.py` `DEVICE_DEFAULTS`.
|
||||
- **OpenVINO uses pre-quantized models** — `compute_type` selects which HF repo to download, not a runtime parameter.
|
||||
|
||||
## Testing
|
||||
|
||||
All tests mock backends — no real model downloads or transcription. Key test patterns:
|
||||
|
||||
- CLI tests: `typer.testing.CliRunner` + mocks for `load_config`, `detect_device`, `load_model`, `_transcribe_file`, `write_transcript`
|
||||
- `_single_patches()` — helper assembling standard happy-path mock set
|
||||
- `_make_result()` / `_make_tfr()` — factories for test data
|
||||
|
||||
## Common tasks
|
||||
|
||||
- **New CLI option**: add `typer.Option` in `cli.py:main()` → add key to `HARDCODED_DEFAULTS` in `config.py` → write test
|
||||
- **New audio/video format**: add extension to `SUPPORTED_EXTENSIONS` in `utils.py`
|
||||
- **New backend**: implement `Backend` protocol → add device mapping in `backends/__init__.py` → add device-aware defaults in `config.py`
|
||||
- **Change output format**: edit `format_transcript()` in `formatter.py`
|
||||
|
||||
## Project docs
|
||||
|
||||
- `docs/PRD.md` — product requirements and scope
|
||||
- `docs/backlog.md` — future experiments and ideas
|
||||
- `docs/gpu.md` — GPU benchmarks, platform compatibility details
|
||||
- `docs/adr/` — architecture decision records (CUDA bootstrap, batch mode, pluggable backends, compute-type defaults, ONNX-ASR evaluation)
|
||||
|
||||
## Agent skills
|
||||
|
||||
### Issue tracker
|
||||
|
||||
Задачи ведутся в Gitea через `tea`; GitHub используется только как зеркало, внешние PR не входят в triage. См. `docs/agents/issue-tracker.md`.
|
||||
|
||||
### Triage labels
|
||||
|
||||
Используются стандартные пять triage-меток. См. `docs/agents/triage-labels.md`.
|
||||
|
||||
### Domain docs
|
||||
|
||||
Репозиторий использует single-context layout. См. `docs/agents/domain.md`.
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
# Локальная транскрипция
|
||||
|
||||
Контекст описывает язык проекта для локального распознавания аудио и видео.
|
||||
|
||||
## Language
|
||||
|
||||
**Движок распознавания**:
|
||||
Программная среда, которая загружает и исполняет модели распознавания. Обновление движка само по себе не означает изменение выбранной модели или рекомендаций пользователю.
|
||||
_Avoid_: Модель, ASR-модель
|
||||
|
||||
**Поддерживаемая модель**:
|
||||
Модель, которую пользователь может выбрать явно и для которой проект обеспечивает работоспособный путь транскрипции. Этот статус не означает автоматический выбор или рекомендацию для большинства пользователей.
|
||||
_Avoid_: Доступная модель, дефолт
|
||||
|
||||
**Модель по умолчанию**:
|
||||
Поддерживаемая модель, которую проект выбирает без явного указания модели пользователем для определённого пути выполнения.
|
||||
_Avoid_: Рекомендуемая модель, поддерживаемая модель
|
||||
@@ -8,8 +8,9 @@ transcribe meeting.mp4
|
||||
```
|
||||
|
||||
- **Полностью локально** — данные не покидают машину
|
||||
- **Авто-ускорение** — NVIDIA CUDA, Intel GPU (OpenVINO), OpenVINO CPU или CPU fallback
|
||||
- **Авто-ускорение** — NVIDIA CUDA, Intel GPU (OpenVINO), ONNX (CPU), OpenVINO CPU или CPU fallback
|
||||
- **Батч-режим** — обработка нескольких файлов за один вызов
|
||||
- **Из проводника Windows** — пункт Transcribe в меню «Отправить» ([установка](#контекстное-меню-проводника-windows))
|
||||
- **Markdown с таймкодами** — удобен для суммаризации ИИ
|
||||
- **Аудио и видео** — mp3, wav, mp4, mkv и [другие форматы](#поддерживаемые-форматы)
|
||||
|
||||
@@ -114,6 +115,12 @@ transcribe podcast.wav --model large-v3 --compute-type float16
|
||||
# Максимальное качество на Intel GPU
|
||||
transcribe podcast.wav --model large-v3 --device openvino-gpu
|
||||
|
||||
# Максимальная скорость на CPU (русский)
|
||||
transcribe meeting.mp4 --device onnx --model gigaam-v3
|
||||
|
||||
# CPU с пунктуацией (русский, для parakeet-v3 нужен явный язык)
|
||||
transcribe podcast.wav --device onnx --model parakeet-v3 --language ru
|
||||
|
||||
# Сохранить в конкретный файл
|
||||
transcribe interview.m4a --output result.md
|
||||
```
|
||||
@@ -138,6 +145,31 @@ transcribe *.mp4 --force
|
||||
- При ошибке в одном файле остальные продолжают обрабатываться
|
||||
- `--output` несовместим с несколькими файлами
|
||||
|
||||
### Контекстное меню проводника (Windows)
|
||||
|
||||
Установить пункт `Transcribe` в меню «Отправить»:
|
||||
|
||||
```bash
|
||||
transcribe --install-menu
|
||||
```
|
||||
|
||||
(при запуске из клона репозитория — `uv run transcribe --install-menu`)
|
||||
|
||||
Использование: выделите один или несколько аудио/видеофайлов в проводнике, откройте контекстное меню правой кнопкой. В Windows 11 выберите «Показать дополнительные параметры» или нажмите Shift+F10, затем «Отправить» → «Transcribe». Несколько выделенных файлов передаются в один процесс и обрабатываются одним батчем.
|
||||
|
||||
Удалить пункт меню:
|
||||
|
||||
```bash
|
||||
transcribe --uninstall-menu
|
||||
```
|
||||
|
||||
Если что-то пошло не так, пункт можно удалить вручную: Win+R → `shell:sendto` → удалить `Transcribe.cmd`.
|
||||
|
||||
Известные ограничения:
|
||||
|
||||
- После переноса или пересоздания проекта/venv выполните `--install-menu` заново: внутри `Transcribe.cmd` хранится абсолютный путь к `transcribe.exe`.
|
||||
- Очень большой мультивыбор с суммарной длиной путей ≳8000 символов упирается в лимит командной строки cmd.exe. Обрабатывайте такие файлы частями.
|
||||
|
||||
### Опции CLI
|
||||
|
||||
| Опция | Сокращение | По умолчанию | Описание |
|
||||
@@ -145,8 +177,9 @@ transcribe *.mp4 --force
|
||||
| `--model` | `-m` | `medium` | Модель Whisper |
|
||||
| `--language` | `-l` | `ru` | Язык (ru, en, auto и др.) |
|
||||
| `--output` | `-o` | `<файл>-transcript.md` | Путь к выходному файлу |
|
||||
| `--device` | `-d` | `auto` | Устройство (auto, cpu, cuda, openvino, openvino-gpu, openvino-cpu) |
|
||||
| `--compute-type` | — | float16 (CUDA) / int8 (OpenVINO GPU/CPU) / float32 (CPU) | Тип вычислений |
|
||||
| `--device` | `-d` | `auto` | Устройство (auto, cpu, cuda, openvino, openvino-gpu, openvino-cpu, onnx) |
|
||||
| `--compute-type` | — | float16 (CUDA) / int8 (OpenVINO/ONNX) / float32 (CPU) | Тип вычислений |
|
||||
| `--threads` | `-t` | 0 (авто) | Потоки CPU (рекомендуется = число физ. ядер) |
|
||||
| `--force` | `-f` | — | Перезаписать существующие транскрипты |
|
||||
| `--verbose` | `-v` | — | Подробный вывод |
|
||||
|
||||
@@ -157,6 +190,7 @@ transcribe *.mp4 --force
|
||||
| CPU | ✅ | ✅ | ✅ |
|
||||
| OpenVINO (x86 CPU) | ✅ авто | — | ✅ авто |
|
||||
| OpenVINO (Intel GPU) | ✅ авто | — | ✅ авто |
|
||||
| ONNX (CPU) | ✅ явно | ✅ явно | ✅ явно |
|
||||
| GPU (NVIDIA) | ✅ авто | — | ✅ (нужен CUDA 12) |
|
||||
|
||||
<details>
|
||||
@@ -209,11 +243,11 @@ language = "en"
|
||||
|
||||
Дефолты зависят от устройства:
|
||||
|
||||
| Параметр | CUDA | OpenVINO (GPU) | OpenVINO (CPU) | CPU |
|
||||
|----------|------|----------------|----------------|-----|
|
||||
| model | medium | medium | medium | medium |
|
||||
| compute_type | float16 | int8 | int8 | float32 |
|
||||
| language | ru | ru | ru | ru |
|
||||
| Параметр | CUDA | OpenVINO (GPU) | OpenVINO (CPU) | ONNX | CPU |
|
||||
|----------|------|----------------|----------------|------|-----|
|
||||
| model | medium | medium | medium | gigaam-v3 | medium |
|
||||
| compute_type | float16 | int8 | int8 | int8 | float32 |
|
||||
| language | ru | ru | ru | ru | ru |
|
||||
|
||||
## Модели и GPU
|
||||
|
||||
@@ -221,6 +255,8 @@ language = "en"
|
||||
- **По умолчанию:** `medium` — хороший баланс скорости и качества
|
||||
- **Макс. качество (NVIDIA):** `large-v3` + `--compute-type float16`
|
||||
- **Макс. качество (Intel GPU):** `large-v3` + `--device openvino-gpu`
|
||||
- **Макс. скорость CPU (русский):** `--device onnx --model gigaam-v3` (17-29× RTF, без пунктуации; рекомендуется LLM-нормализация терминов после)
|
||||
- **CPU с пунктуацией (русский):** `--device openvino-cpu --model medium` (5-6× RTF; для встреч ≤30 мин с равномерной громкостью — на длинных файлах с тихими фрагментами возможны галлюцинации)
|
||||
- **Быстрый тест:** `tiny` — для проверки пайплайна
|
||||
|
||||
<details>
|
||||
@@ -234,6 +270,17 @@ language = "en"
|
||||
| `medium` | ~1.5 GB | ~2.5 GB | ★★ | ★★★★ |
|
||||
| `large-v3` | ~3 GB | ~2.5 GB | ★ | ★★★★★ |
|
||||
|
||||
#### ONNX-модели (`--device onnx`)
|
||||
|
||||
Другие архитектуры, не Whisper. Работают через onnxruntime на CPU:
|
||||
|
||||
| Модель | Размер (int8) | RTFx CPU | Языки | Пунктуация |
|
||||
|--------|--------------|----------|-------|-----------|
|
||||
| `gigaam-v3` | ~300 MB | 17-29× | ru | ❌ |
|
||||
| `parakeet-v3` | ~600 MB | 12-20× | 25 языков | ✅ |
|
||||
|
||||
> **Рекомендация**: для русского — `gigaam-v3` (единственный из onnx-моделей, дающий пригодный для конспекта транскрипт на русских встречах; см. [ADR-006](docs/adr/006-onnx-asr-backend.md)). `parakeet-v3` уместен только для англоязычного / multilingual контента — на русском воспроизводит проблемы из [ADR-005](docs/adr/005-parakeet-evaluation.md) (Mm-hmm-редукция тихих реплик, иноязычные вставки).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -243,6 +290,7 @@ language = "en"
|
||||
|-----|--------|----------|----------|--------------------|
|
||||
| `float16` | CUDA | ~4.5-5 GB | Отлично | **По умолчанию для CUDA** |
|
||||
| `int8_float16` | CUDA | ~4.7 GB | Отлично | GPU от 6 GB, альтернатива float16 |
|
||||
| `int8_float32` | CPU | Среднее | Отлично | **Рекомендуется для CPU** — 1.5x быстрее float32 при том же качестве |
|
||||
| `int8` | CUDA / OpenVINO | Низкое | Хорошо, но бывают галлюцинации | **По умолчанию для OpenVINO** |
|
||||
| `fp16` | OpenVINO | Низкое | Отлично | OpenVINO large-v3 (выбирается автоматически) |
|
||||
| `float32` | CPU | Среднее | Отлично | **По умолчанию для CPU** |
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# ADR-005: NVIDIA Parakeet TDT — оценено, отклонено для дефолтного use case
|
||||
|
||||
**Статус**: Отклонено (оставлено в экспериментальной ветке)
|
||||
**Дата**: 2026-04-19
|
||||
|
||||
## Контекст
|
||||
|
||||
ADR-003 и ADR-004 зафиксировали стратегию: CTranslate2 на CPU (качество) +
|
||||
OpenVINO (скорость на x86). Оба бэкенда — Whisper. Возникла гипотеза, что
|
||||
**NVIDIA Parakeet TDT 0.6B v3** может выйти за пределы этого trade-off: модель
|
||||
заявлена multilingual (25 языков включая русский), имеет ONNX-порт
|
||||
[istupakov/parakeet-tdt-0.6b-v3-onnx](https://huggingface.co/istupakov/parakeet-tdt-0.6b-v3-onnx),
|
||||
работает через `onnx-asr` + Silero VAD. Есть позитивные отзывы (напр.
|
||||
приложение Handy использует Parakeet для dictation в реальном времени).
|
||||
|
||||
Основное применение проекта — транскрипция русскоязычных ИТ-встреч
|
||||
(ментор ↔ менти, ~15–60 мин, ноутбучный микрофон). Если Parakeet обойдёт
|
||||
Whisper medium int8 по скорости при сравнимом качестве, или по качеству при
|
||||
сравнимой скорости — это основание поменять дефолт.
|
||||
|
||||
## Эксперимент
|
||||
|
||||
Реализован полный бэкенд в ветке `feature/parakeet-backend` (ONNX Runtime CPU EP,
|
||||
Silero VAD, cache-first model loading). Прогнан автоматический бенчмарк
|
||||
на двух 15-минутных отрывках реальных установочных встреч
|
||||
(`scripts/quality_bench.py` в ветке), CPU Intel i7-11800H.
|
||||
|
||||
Качество оценивал агент-судья по шкале 1–5 по критериям completeness /
|
||||
term accuracy / fluency / summary utility — на пригодность транскрипта
|
||||
для построения конспекта без возврата к аудио.
|
||||
|
||||
### Скорость и ресурсы
|
||||
|
||||
| Отрывок | Бэкенд | Wall | RTFx | Peak RSS |
|
||||
|---|---|---|---|---|
|
||||
| artur-intro (15 мин) | **parakeet-int8** | 129.5s | **6.92x** | 2.21 GB |
|
||||
| artur-intro (15 мин) | openvino-medium-int8 | 354.2s | 2.54x | 3.50 GB |
|
||||
| mar15-mid (15 мин) | **parakeet-int8** | 159.6s | **5.64x** | 2.65 GB |
|
||||
| mar15-mid (15 мин) | openvino-medium-int8 | 428.4s | 2.10x | 3.52 GB |
|
||||
|
||||
**Parakeet в 2.7x быстрее и использует на ~35% меньше RAM.**
|
||||
|
||||
### Качество (Summary utility, 1–5)
|
||||
|
||||
| Отрывок | Parakeet int8 | Whisper medium int8 |
|
||||
|---|---|---|
|
||||
| artur-intro | **2** | **4** |
|
||||
| mar15-mid | **3** | **4** |
|
||||
|
||||
Систематические проблемы Parakeet на русской речи с низкой громкостью:
|
||||
|
||||
1. **Редукция коротких реплик менти до `Mm-hmm`/`Yeah`** — теряется
|
||||
значительная часть диалога (низкий уровень микрофона менти).
|
||||
2. **Вставки кусков польского/испанского/немецкого** посреди русской речи
|
||||
(`Miejsce w sześć zajmie`, `Que salviosa`, `Genial positions`).
|
||||
3. **Сильное искажение ИТ-терминов и бытовых выражений** —
|
||||
`Pretty subs` вместо «пройти собес», `Состем основ` вместо «система контроля
|
||||
версий», `І на глуп Джой` вместо `inner loop join`.
|
||||
|
||||
Whisper medium int8 даёт связный русский текст с нормальной пунктуацией;
|
||||
искажения присутствуют (`капка` вместо Kafka, одна галлюцинация
|
||||
«Добро пожаловать в наш канал!» на паузе), но не критичны для конспекта.
|
||||
|
||||
### Попытка тюнинга — Silero VAD threshold
|
||||
|
||||
Гипотеза: дефолтный `threshold=0.5` обрезает тихие реплики менти. Проверено
|
||||
на 0.5/0.3/0.2 — снижение порога **уменьшает** объём транскрипта
|
||||
(Parakeet хуже декодирует длинные склеенные сегменты с разреженной речью)
|
||||
и **не снижает** частоту `Mm-hmm`-редукций. Проблема inherent для модели
|
||||
на тихом русском диалоге, не артефакт VAD.
|
||||
|
||||
## Решение
|
||||
|
||||
**Parakeet TDT 0.6B v3 не принимается как дефолтный или опциональный бэкенд
|
||||
для целевого use case.** Whisper medium int8 остаётся предпочтительным —
|
||||
несмотря на 2.7x проигрыш по скорости, он даёт транскрипт, из которого можно
|
||||
сделать конспект без возврата к аудио. Для русской речи с варьирующейся
|
||||
громкостью микрофона Parakeet v3 недостаточно устойчив.
|
||||
|
||||
Код бэкенда **не мержится в master**. Экспериментальная ветка
|
||||
`feature/parakeet-backend` сохраняется как reference для будущих экспериментов.
|
||||
|
||||
## Последствия
|
||||
|
||||
- Архитектура pluggable backends (ADR-003) подтвердила ценность: интегрировать
|
||||
и оценить новый бэкенд оказалось недорого.
|
||||
- Surface area master не растёт: нет `--device parakeet-cpu`, нет зависимости
|
||||
`onnx-asr`, нет новой ветки в CLI.
|
||||
- Если в будущем появится интерес — ветка `feature/parakeet-backend` содержит
|
||||
готовый бэкенд (13 задач, ~19 тестов), скрипты бенчмарка и VAD-тюнинга.
|
||||
|
||||
## Когда стоит пересмотреть решение
|
||||
|
||||
1. **Англоязычный контент** — Parakeet обучался преимущественно на английском,
|
||||
для native English встреч выигрыш в скорости может окупить качество.
|
||||
2. **Live-captioning / dictation** (как в Handy) — другой use case, близкий
|
||||
микрофон + короткие фразы, проблемы редукции неактуальны.
|
||||
3. **Русские specialized-модели через ту же обвязку** `onnx-asr`:
|
||||
`gigaam-v3-rnnt` (SberDevices), `nemo-fastconformer-ru-rnnt` (NVIDIA),
|
||||
`t-one-ctc` (T-Bank). Это отдельная ветка экспериментов, не Parakeet.
|
||||
4. **Слабые машины с ограниченной RAM** — Parakeet 2.2 GB vs Whisper 3.5 GB
|
||||
на 15-мин аудио. Если важна RAM-экономия и английский контент.
|
||||
@@ -0,0 +1,135 @@
|
||||
# ADR-006: onnx-asr бэкенд — GigaAM v3 как CPU-default для русских встреч
|
||||
|
||||
**Статус**: Принято
|
||||
**Дата**: 2026-04-25
|
||||
|
||||
## Контекст
|
||||
|
||||
Целевая аудитория `local-transcriber` — пользователи с Intel iGPU / CPU, без дискретного NVIDIA GPU.
|
||||
Существующие CPU-бэкенды (faster-whisper, OpenVINO) дают 0.5-6x RTF для средних моделей — транскрипция часовой записи занимает 10-120 минут.
|
||||
|
||||
[onnx-asr](https://github.com/istupakov/onnx-asr) — легковесная обёртка (onnxruntime + numpy) над ONNX-моделями Parakeet, GigaAM, FastConformer и Canary. Заявляет 30-90x RTF на CPU при сравнимом с Whisper качестве для русского языка.
|
||||
|
||||
[ADR-005](005-parakeet-evaluation.md) ранее отклонил Parakeet TDT 0.6B v3 для целевого use case на двух 15-минутных файлах с тихим микрофоном менти и плотной IT-терминологией. ADR-006 повторяет эксперимент на новом наборе файлов (22-81 мин, разной громкости), добавляет GigaAM v3 как кандидата для русской речи и проверяет три класса проблем Parakeet из ADR-005 на актуальном материале.
|
||||
|
||||
## Эксперимент
|
||||
|
||||
Три реальных русскоязычных записи установочных встреч (формат ментор↔менти, mp4, 22-81 мин), ноутбук с Intel i7-11800H (CPU-only). Сравнивались три CPU-бэкенда:
|
||||
|
||||
- **gigaam-v3** (onnx-asr GigaAM v3 CTC, int8, monolingual ru, без пунктуации)
|
||||
- **parakeet-v3** (onnx-asr Parakeet TDT 0.6B v3, int8, multilingual)
|
||||
- **ov-medium** (OpenVINO Whisper medium int8) — baseline
|
||||
|
||||
Качественная оценка проведена независимым agent-judge'ем по методологии ADR-005 (4 критерия: completeness / term accuracy / fluency / summary utility, шкала 1-5). Транскрипты сохранены в `/mnt/c/ddmitry/Videos/OBS/<basename>.{onnx-gigaam,parakeet-v3,ov-medium}.md` и доступны для верификации.
|
||||
|
||||
### Скорость
|
||||
|
||||
| Файл | Длит. | gigaam-v3 | parakeet-v3 --ru | OpenVINO medium |
|
||||
|---|---|---|---|---|
|
||||
| 10-59-59 | 22:26 | 81с (16.6×) | 116с (11.6×) | 265с (5.1×) |
|
||||
| Vasya | 45:51 | 95с (29×) | 138с (20×) | 455с (6×) |
|
||||
| 12-02-37 | 1:20:44 | 170с (28.5×) | 251с (19.3×) | 779с (6.2×) |
|
||||
|
||||
GigaAM в 3-5× быстрее OpenVINO medium, Parakeet в 2.5-3.5× быстрее.
|
||||
|
||||
### Качество (agent-judge, 1-5)
|
||||
|
||||
| Файл | Backend | Completeness | Term accuracy | Fluency | Summary utility |
|
||||
|------|---------|:-:|:-:|:-:|:-:|
|
||||
| 10-59-59 | **gigaam** | **5** | **4** | **4** | **4** |
|
||||
| 10-59-59 | parakeet | 4 | 2 | 2 | 2 |
|
||||
| 10-59-59 | ov-medium | 3 | 4 | 4 | 2 |
|
||||
| Vasya | **gigaam** | 3 | **4** | **4** | **4** |
|
||||
| Vasya | parakeet | 2 | 2 | 2 | 2 |
|
||||
| Vasya | ov-medium | 2 | 3 | 2 | 2 |
|
||||
| 12-02-37 | **gigaam** | 4 | 3 | 3 | **4** |
|
||||
| 12-02-37 | parakeet | 3 | 2 | 1 | 1 |
|
||||
| 12-02-37 | ov-medium | 2 | 3 | 2 | 1 |
|
||||
|
||||
GigaAM — единственный backend, дающий summary utility 4/5 на всех трёх файлах. Parakeet и ov-medium систематически уступают по разным причинам (см. ниже).
|
||||
|
||||
### Класс ошибок: Parakeet — три проблемы из ADR-005 воспроизведены
|
||||
|
||||
Все три класса систематических ошибок Parakeet, описанные в [ADR-005](005-parakeet-evaluation.md), воспроизводятся на новом наборе файлов:
|
||||
|
||||
**1. Mm-hmm/Yeah-редукция тихих реплик менти.** Массово на всех трёх файлах:
|
||||
- Vasya `[04:56-09:33]` блок из ~10 реплик: `Mm-hmm. Mm-hmm. Mm. That's nice. Mm-hmm. Mm-hmm.` — полностью утеряны ответы менти на вопросы ментора.
|
||||
- 10-59-59 `[19:03]` `Yeah. Иногда лучше дышали в облаке`.
|
||||
- 12-02-37 `[00:00:01]` `I mean.` вместо «не пони…».
|
||||
|
||||
**2. Вставки иностранных языков посреди русского.** На этом наборе ещё агрессивнее, чем в ADR-005 (там был только польский):
|
||||
- 10-59-59 `[00:08]` `Secondo, Alice. The mutual microphone.` — итальянский+английский для «секунду, Алиса, замьючен микрофон».
|
||||
- 10-59-59 `[22:02]` `Ah si va sur. Well.` — испано-французская смесь в финальном прощании.
|
||||
- Vasya `[27:31]` `Mas o żegnienie.` — польский в полностью русской встрече.
|
||||
- 12-02-37 `[10:38]` `Запроси к Każdemu Actually, таблица классная` — русско-польско-английский в одной фразе.
|
||||
- 12-02-37 `[01:03:23]` `No już je wsie.` — польский («ну уже всё»).
|
||||
|
||||
**3. Искажение IT-терминов и имён компаний:**
|
||||
- 10-59-59 `[02:21]` `не Аринадата и не Терринте игра` вместо «Аренадата и Тере-Интегра» (имена работодателей).
|
||||
- 10-59-59 `[01:23]` `Запромбанке` (с unk-токенами) вместо «Газпромбанк».
|
||||
- 12-02-37 `[02:35]` `Basic space clear cause` вместо «база данных кликхаус».
|
||||
- 12-02-37 `[06:12]` `Поскре это не колочный, чтобы это греплан. Ловочная.` — Postgres/Greenplum/«колоночная» искажены до неразборчивости.
|
||||
- 12-02-37 `[16:53]` `своеобресть` вместо «Wildberries» — целевой работодатель в задаче, имя потеряно.
|
||||
|
||||
### Класс ошибок: Whisper medium — галлюцинации на длинных файлах с тихими фрагментами
|
||||
|
||||
Не описано в ADR-005 (там были 15-минутные отрывки) — обнаружено только на длинных файлах:
|
||||
|
||||
- 12-02-37 `[01:03:43-01:20:14]` — **17 минут хвоста встречи** забиты галлюцинированными повторами: `«Вместе с вами мы решим, как мы будем работать с вами»`, `«Это не то, чтобы не было»`, `«Выбор? Нет. Выбор? Нет.»`. Бытовая часть встречи целиком потеряна.
|
||||
- 12-02-37 `[19:54-20:49]` — 11 повторов `«И вот, как я вам рассказываю, это очень интересно»` вместо реального решения SQL-задачи.
|
||||
- Vasya `[10:08-11:59]` — ~6 повторов `«Но если вы хотите, чтобы мы не разговаривали, то вы можете.»` (~2 минуты галлюцинации).
|
||||
- Vasya `[36:00-36:30]` — 14 повторов `«Ага. Ага.»` (loop).
|
||||
- Vasya `[45:51]` — `«Субтитры сделаны с помощью СМС, аппарата — Лариса.»` — классический Whisper-артефакт «титров».
|
||||
- 10-59-59 `[10:56-11:40]` — строка из ~1000 символов `«ааааа…»` — галлюцинация на тихом фрагменте, проглатывает 30 секунд аудио.
|
||||
- 12-02-37 `[01:18:47]` — приписан несуществующий человек `«Валерий Сюткин»`.
|
||||
|
||||
Это критичный класс ошибок: текст выглядит правдоподобно, и читатель конспекта не отличит галлюцинацию от реального содержания без возврата к аудио. Хуже потери — потому что вводит в заблуждение.
|
||||
|
||||
### Класс ошибок: GigaAM — локальные искажения латиницы и имён
|
||||
|
||||
GigaAM monolingual ru, латиницу не выдаёт. На транскрипте:
|
||||
- `«эскель»`/`«эсквель»` вместо `SQL` (везде кириллицей).
|
||||
- `«гитам ардауна»` вместо `git и markdown` (Vasya `[14:14]`).
|
||||
- `«арендата»`/`«арендат»`/`«арендода»` для «Аренадата» (10-59-59 `[02:21]`) — три разных варианта одного имени.
|
||||
- `«дв один»` вместо `DEV1` (12-02-37 `[00:50:56]`).
|
||||
- `«яндекс тим под яндекс тим»` для «Яндекс ТимКод» (12-02-37 `[00:14:15]`).
|
||||
|
||||
Mm-hmm-редукция и иностранные вставки **не обнаружены**: monolingual архитектура исключает language-confusion, тихие реплики менти остаются как русские «угу/да/ну».
|
||||
|
||||
Эти ошибки локальны, предсказуемы и легко чинятся LLM-этапом нормализации без знания исходного аудио (восстановить SQL, Greenplum, ClickHouse, имена компаний из контекста).
|
||||
|
||||
## Решение
|
||||
|
||||
**Принять onnx-asr как экспериментальный бэкенд с явным `--device onnx`. GigaAM v3 — рекомендуемая модель для русских встреч на CPU.**
|
||||
|
||||
Бэкенд **не в auto-detect** — только при явном указании пользователем (политика experimental backend, как для openvino).
|
||||
|
||||
Модели:
|
||||
- **`gigaam-v3`** — рекомендуемая для русских встреч на CPU. 17-29× RTF, summary utility 4/5 на всех протестированных файлах. Без пунктуации, без латиницы; ошибки локальны, чинятся LLM-нормализацией.
|
||||
- **`parakeet-v3`** — multilingual (25 языков), формально доступен. **Не рекомендуется для русских встреч**: Mm-hmm-редукция и иноязычные вставки воспроизводятся систематически (см. выше). Уместен только для англоязычного контента.
|
||||
|
||||
Обе модели в int8-квантизации (~300 MB).
|
||||
|
||||
## Последствия
|
||||
|
||||
- Пользователи CPU-only с русскоязычным контентом получают 3-5× ускорение по сравнению с OpenVINO medium **при превосходящем качестве** (4/5 vs 1-2/5 summary utility на длинных файлах).
|
||||
- Whisper medium (`--device openvino-cpu`) **остаётся допустимым** для коротких (≤30 мин) встреч с равномерной громкостью; на длинных файлах с тихими участками он галлюцинирует целыми блоками — этот риск зафиксирован, но решение не выводит OpenVINO из списка дефолтов (часть пользователей всё ещё нуждается в пунктуации, и для коротких файлов галлюцинации не воспроизводятся).
|
||||
- Parakeet-v3 формально доступен, но в README рекомендуется только для англоязычного контента — для русского явно не годится.
|
||||
- GPU faster-whisper large-v3 остаётся эталоном по качеству (для пользователей с NVIDIA GPU).
|
||||
- Пост-процессинг GigaAM-транскрипта LLM-этапом нормализации (восстановление латинских терминов и имён компаний) — рекомендуемая практика для финального конспекта.
|
||||
|
||||
## Открытые вопросы / следующие шаги
|
||||
|
||||
- **Galлюцинации Whisper medium на длинных файлах** — отдельный продуктовый риск, требующий собственного исследования. Возможно, имеет смысл ограничить максимальную длину чанка для openvino-medium, или дать предупреждение пользователю.
|
||||
- **GigaAM v3 RNN-T** (вариант `gigaam-v3-rnnt` вместо `gigaam-v3-ctc`) — заявлен как немного качественнее CTC, не тестировался. Может закрыть часть GigaAM-ошибок на латинице.
|
||||
- **Canary** (`nemo-canary-1b-v2`) — тяжелее, но multilingual + пунктуация. Кандидат на «лучшее качество за разумную скорость» для тех, кому важна пунктуация.
|
||||
- **Auto-detect onnx**: после стабилизации в production-использовании (несколько недель) — рассмотреть включение в auto-detect как первый CPU-бэкенд (ниже CUDA, выше OpenVINO).
|
||||
|
||||
## Отклонённые альтернативы
|
||||
|
||||
| Альтернатива | Почему отклонена |
|
||||
|---|---|
|
||||
| NeMo Parakeet напрямую (без onnx-asr) | Требует PyTorch + CUDA, Python ≥ 3.12, ~2 GB зависимостей — слишком тяжело для CLI |
|
||||
| Замена faster-whisper на onnx-asr | faster-whisper поддерживает 99+ языков и пунктуацию, остаётся лучшим GPU-бэкендом |
|
||||
| GigaAM как auto-detect default | Экспериментальный бэкенд, политика — не сюрпризить существующих пользователей; включение в auto-detect — после периода стабилизации |
|
||||
| Parakeet-v3 как multilingual default | Воспроизведённые проблемы из ADR-005 (Mm-hmm-редукция, иноязычные вставки) делают его непригодным для русского; для других языков не валидировано в этом эксперименте |
|
||||
@@ -0,0 +1,47 @@
|
||||
# Domain Docs
|
||||
|
||||
Репозиторий использует single-context layout.
|
||||
|
||||
## Перед исследованием кода
|
||||
|
||||
- Прочитать корневой `CONTEXT.md`.
|
||||
- Прочитать относящиеся к задаче решения из `docs/adr/`.
|
||||
- Проверить относящиеся к задаче исследования в `docs/research/`.
|
||||
- Проверить относящиеся к задаче спецификации в `docs/specs/`.
|
||||
- Если документа нет, продолжить молча: доменные документы создаются лениво
|
||||
соответствующими навыками.
|
||||
|
||||
## Структура
|
||||
|
||||
```text
|
||||
/
|
||||
├── CONTEXT.md
|
||||
├── docs/
|
||||
│ ├── adr/
|
||||
│ ├── research/
|
||||
│ │ └── YYYY-MM-DD-slug.md
|
||||
│ └── specs/
|
||||
│ └── YYYY-MM-DD-slug.md
|
||||
└── src/
|
||||
```
|
||||
|
||||
## Назначение документов
|
||||
|
||||
- `CONTEXT.md` — каноническая терминология предметной области.
|
||||
- `docs/adr/` — принятые архитектурные решения и их обоснование.
|
||||
- `docs/research/YYYY-MM-DD-slug.md` — результаты исследований, основанные на
|
||||
источниках и экспериментах.
|
||||
- `docs/specs/YYYY-MM-DD-slug.md` — согласованные спецификации изменений.
|
||||
|
||||
## Терминология
|
||||
|
||||
В задачах, тестах, предложениях и документации использовать термины из
|
||||
`CONTEXT.md`. Не заменять их синонимами, перечисленными в `_Avoid_`.
|
||||
|
||||
Если нужного понятия нет, проверить, действительно ли это доменный термин.
|
||||
Существенный пробел передать в `domain-modeling`.
|
||||
|
||||
## Конфликты с ADR
|
||||
|
||||
Если предлагаемое изменение противоречит существующему ADR, указать конфликт
|
||||
явно и объяснить, почему решение стоит пересмотреть.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Issue Tracker
|
||||
|
||||
Задачи проекта ведутся в Gitea-репозитории `ddmitry/local-transcriber`.
|
||||
|
||||
- Основной remote: `origin`
|
||||
- Gitea: `https://git.dementev.space`
|
||||
- CLI: `tea`
|
||||
- Remote `github` является зеркалом и не используется для управления задачами
|
||||
- Внешние pull request не входят в очередь triage
|
||||
|
||||
## Доступ
|
||||
|
||||
Перед операциями с задачами проверить наличие `tea`.
|
||||
|
||||
Если команда недоступна, остановиться и предложить пользователю установку:
|
||||
|
||||
```powershell
|
||||
winget install --id Gitea.tea --exact
|
||||
```
|
||||
|
||||
Не переключаться автоматически на GitHub Issues или локальные markdown-задачи.
|
||||
|
||||
Проверить настроенные подключения:
|
||||
|
||||
```powershell
|
||||
tea login list
|
||||
```
|
||||
|
||||
Если подходящего подключения нет, предложить пользователю настроить его через
|
||||
`tea login add`. Не запрашивать и не выводить токены в переписке или логах.
|
||||
|
||||
## Прокси
|
||||
|
||||
Рабочее окружение использует корпоративный прокси (`HTTP_PROXY` и `HTTPS_PROXY`),
|
||||
через который `git.dementev.space` недоступен: запрос к API завершается ошибкой
|
||||
`EOF`. Хост нужно добавить в `NO_PROXY`.
|
||||
|
||||
Разделитель — **запятая**, не точка с запятой: `tea` написан на Go, а Go
|
||||
разбирает `NO_PROXY` по запятым, и хост после `;` не распознаётся.
|
||||
|
||||
На текущую сессию:
|
||||
|
||||
```powershell
|
||||
$env:NO_PROXY = "$env:NO_PROXY,git.dementev.space"
|
||||
```
|
||||
|
||||
Постоянно, в пользовательских переменных окружения (значение подхватят только
|
||||
новые процессы):
|
||||
|
||||
```powershell
|
||||
[Environment]::SetEnvironmentVariable("NO_PROXY", "$env:NO_PROXY,git.dementev.space", "User")
|
||||
```
|
||||
|
||||
## Работа с задачами
|
||||
|
||||
Из рабочего дерева использовать Gitea remote `origin`:
|
||||
|
||||
```powershell
|
||||
tea issues list --remote origin
|
||||
tea issues create --remote origin
|
||||
tea issues edit <index> --remote origin
|
||||
tea labels list --remote origin
|
||||
```
|
||||
|
||||
За пределами рабочего дерева явно указывать репозиторий
|
||||
`ddmitry/local-transcriber` и настроенный Gitea login.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Triage Labels
|
||||
|
||||
Инженерные навыки используют пять канонических triage-ролей. В Gitea им
|
||||
соответствуют одноимённые метки.
|
||||
|
||||
| Роль навыка | Метка Gitea | Значение |
|
||||
| --- | --- | --- |
|
||||
| `needs-triage` | `needs-triage` | Требует оценки сопровождающим |
|
||||
| `needs-info` | `needs-info` | Ожидает дополнительной информации от автора |
|
||||
| `ready-for-agent` | `ready-for-agent` | Полностью описана и готова для автономного агента |
|
||||
| `ready-for-human` | `ready-for-human` | Требует реализации человеком |
|
||||
| `wontfix` | `wontfix` | Выполняться не будет |
|
||||
|
||||
Когда навык упоминает triage-роль, следует использовать соответствующую метку
|
||||
из этой таблицы.
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
# Backlog — будущие эксперименты и направления
|
||||
|
||||
Список открытых направлений, которые имеют смысл, но не реализованы. Каждый пункт содержит обоснование и ссылку на источник (ADR / статья), чтобы при возврате не пришлось воспроизводить контекст с нуля.
|
||||
|
||||
Когда направление становится в работу — переносится в spec/план или соответствующий ADR. Когда отклоняется — остаётся в backlog с пометкой «отклонено» и причиной (для истории решений).
|
||||
|
||||
---
|
||||
|
||||
## ASR-бэкенды и модели
|
||||
|
||||
### Переоценка CPU-дефолта после обновления `onnx-asr` 0.12
|
||||
|
||||
**Проблема:** текущий CPU-путь через OpenVINO Whisper medium нестабилен на
|
||||
длинных записях с тихими участками: модель генерирует правдоподобные повторы и
|
||||
несуществующий текст. На файле `2026-07-29 13-58-39.mp4` разговор заканчивается
|
||||
примерно на 02:28, после чего OpenVINO создаёт десятки повторяющихся блоков до
|
||||
конца 59-минутной записи. Изолированный тест окна 02:00–03:30 дал одинаковую
|
||||
серию из 14 повторов на `openvino-genai` 2026.0 и 2026.3; обновление движка само
|
||||
по себе проблему не устраняет. Подробнее — в пункте
|
||||
[«Whisper medium галлюцинации»](#whisper-medium-галлюцинации-на-длинных-файлах-с-тихими-фрагментами).
|
||||
|
||||
**Порядок работы:**
|
||||
|
||||
1. Сначала обновить совместимый стек по
|
||||
[исследованию обновлений](research/2026-08-10-engine-model-updates.md): снять
|
||||
ограничение `onnx-asr<0.12.0`, проверить VAD и квантованные модели; OpenVINO и
|
||||
OpenVINO GenAI обновлять согласованно. Не фиксировать ONNX Runtime 1.28 для
|
||||
Python 3.10.
|
||||
Шаг 1 в части onnx-пути вынесен в отдельную работу —
|
||||
[спека «Onnx-каталог, Python 3.13 и границы версий»](specs/2026-08-11-onnx-model-catalog.md): движок
|
||||
обновляется, три модели становятся поддерживаемыми, дефолт и OpenVINO не
|
||||
трогаются намеренно, чтобы сохранить точку отсчёта для будущего сравнения.
|
||||
2. После интеграционных тестов провести сравнительный бенчмарк моделей. Не
|
||||
менять CPU-дефолт только по model card или результату на одном файле.
|
||||
3. Решение зафиксировать в ADR-007, обновить рекомендации README и только затем
|
||||
менять auto-detect/defaults.
|
||||
|
||||
**Кандидаты:**
|
||||
|
||||
- `gigaam-v3-ctc` — текущий baseline для русского CPU.
|
||||
- `gigaam-v3-rnnt` — контекстный декодер без пунктуации.
|
||||
- `gigaam-v3-e2e-ctc` и `gigaam-v3-e2e-rnnt` — варианты с пунктуацией и
|
||||
нормализацией текста.
|
||||
- `gigaam-multilingual-ctc` и `gigaam-multilingual-large-ctc`, добавленные в
|
||||
`onnx-asr` 0.12 — кандидаты для смешанной речи, но не априори для русского
|
||||
дефолта.
|
||||
- OpenVINO medium + VAD — альтернатива смене модели, сохраняющая Whisper и
|
||||
пунктуацию.
|
||||
- Parakeet v3 — контрольный multilingual-вариант, но не кандидат на русский
|
||||
дефолт без новых данных, опровергающих ADR-005/006.
|
||||
|
||||
**Матрица качества:**
|
||||
|
||||
- те же три полные записи и четыре критерия agent-judge из
|
||||
[ADR-006](adr/006-onnx-asr-backend.md): completeness, term accuracy, fluency,
|
||||
summary utility;
|
||||
- новый негативный пример `2026-07-29 13-58-39.mp4` с длинной тишиной;
|
||||
- небольшой вручную проверенный набор сложных фрагментов для WER/CER и разбора
|
||||
критичных смысловых ошибок: тихие реплики, имена компаний, латиница и
|
||||
IT-термины, числа, русско-английское переключение;
|
||||
- автоматические проверки потери хвоста, повторов, пустых/нулевых VAD-сегментов
|
||||
и выдуманного текста на тишине.
|
||||
|
||||
**Матрица производительности:**
|
||||
|
||||
- обязательный прогон на реальном Intel Core i5 11-го поколения; точный SKU,
|
||||
число ядер, объём RAM, power mode и число потоков записать вместе с результатом;
|
||||
- Ryzen 7 8845H разработчика и ограничение числа потоков использовать только для
|
||||
предварительного smoke-теста, не как приёмку производительности i5;
|
||||
- измерять отдельно cold start/загрузку модели и warm transcription, медиану
|
||||
трёх прогонов, RTFx, peak RSS и размер скачиваемой модели;
|
||||
- короткий 15-минутный фрагмент нужен для итераций, полные записи 22–81 мин —
|
||||
для итогового решения и проверки устойчивости.
|
||||
|
||||
**Почему интересно:**
|
||||
|
||||
- **WER 2.6% vs 13.2%** для CTC на сложных текстах — в 5 раз ниже на разговорной речи и доменной лексике (источник: SberDevices / Хабр-публикация GigaAM-v3).
|
||||
- **Контекстный декодер** — структурно решает основную проблему GigaAM-CTC из ADR-006: кириллизация латиницы и искажения имён компаний (`Запромбанк` → `Газпромбанк`, `яндекс тим под яндекс тим` → `Яндекс ТимКод`). RNN-T видит контекст уже сгенерированных токенов и может «дотянуть» имена.
|
||||
- **`v3_e2e_rnnt` с пунктуацией и нормализацией** — закрывает главное ограничение GigaAM-CTC, ради которого в README сейчас стоит fallback на `openvino-cpu medium` (с задокументированными в ADR-006 галлюцинациями на длинных файлах).
|
||||
- **70:30 vs Whisper-large-v3** — GigaAM-v3 (CTC и RNN-T) выигрывает у `large-v3` по LLM-as-Judge (Gemini 2.5 Pro). Если переносится на наш use case — RNN-T на CPU становится сильнее GPU faster-whisper large-v3.
|
||||
- **30% лучше на «новых доменах»** (callcenter-like речь, нестандартные характеристики) — это и есть домен установочных встреч.
|
||||
|
||||
**Tradeoff:**
|
||||
|
||||
- Скорость ниже CTC (RNN-T декодинг последовательный). Реалистичная оценка: 10-15× RTF на CPU вместо 17-29× у CTC. Всё ещё в 1.5-2× быстрее Whisper medium.
|
||||
- Размер модели больше (~500 MB int8 против ~300 MB у CTC) — оценка, нужна верификация.
|
||||
|
||||
**Если подтвердится бенчмарком:**
|
||||
|
||||
- Если E2E RNN-T сохраняет качество и приемлемую скорость на i5 — сделать его
|
||||
русским CPU-дефолтом, OpenVINO оставить явной опцией.
|
||||
- Если лучший вариант зависит от языка — выбрать language-aware default:
|
||||
GigaAM v3 для русского, multilingual-модель для смешанной речи.
|
||||
- Если модели GigaAM проигрывают по пунктуации/смыслу — сохранить текущий
|
||||
model default и лечить OpenVINO через VAD/качественный pipeline.
|
||||
- Если ни один вариант не проходит порог качества и скорости — не менять
|
||||
дефолт, оставить предупреждения и явный выбор backend.
|
||||
|
||||
---
|
||||
|
||||
### Canary 1B — multilingual + пунктуация на CPU
|
||||
|
||||
**Что:** Протестировать `nemo-canary-1b-v2` через onnx-asr на тех же 3 файлах.
|
||||
|
||||
**Почему:** Multilingual + пунктуация в одной модели. Кандидат на «лучшее качество за разумную скорость» для пользователей, которым нужны и не-русский контент, и пунктуация одновременно. Упомянут в [ADR-006](adr/006-onnx-asr-backend.md#открытые-вопросы--следующие-шаги).
|
||||
|
||||
**Tradeoff:** Тяжелее GigaAM (~1 GB vs ~300 MB), скорость на CPU ожидаемо ниже. Если RNN-T закроет потребность в пунктуации — Canary становится менее приоритетным.
|
||||
|
||||
---
|
||||
|
||||
## Качество и устойчивость
|
||||
|
||||
### Whisper medium галлюцинации на длинных файлах с тихими фрагментами
|
||||
|
||||
**Статус:** воспроизведено 2026-08-10. Помимо примеров из
|
||||
[ADR-006](adr/006-onnx-asr-backend.md#класс-ошибок-whisper-medium--галлюцинации-на-длинных-файлах-с-тихими-фрагментами),
|
||||
на записи `2026-07-29 13-58-39.mp4` минимальный тест 02:00–03:30 стабильно
|
||||
получает 14 одинаковых сегментов после окончания речи. На 30-секундном окне
|
||||
только с речью повторов нет. `openvino-genai` 2026.3 и
|
||||
`no_repeat_ngram_size=3` результат не меняют. Корень проблемы — длинные
|
||||
безречевые участки, которые текущий OpenVINO backend целиком передаёт в
|
||||
`WhisperPipeline` без VAD.
|
||||
|
||||
**Возможные направления:**
|
||||
|
||||
- Добавить VAD перед OpenVINO WhisperPipeline и сохранить исходные таймкоды —
|
||||
наиболее прямое лечение подтверждённой причины.
|
||||
- Ограничить длину чанка для openvino-medium (chunk_length параметр в WhisperPipeline).
|
||||
- Внедрить `compression_ratio_threshold` / `log_prob_threshold` фильтры через переписывание pipeline (как у CTranslate2). Уже частично описано в [docs/gpu.md «Качественный pipeline для OpenVINO»](gpu.md#качественный-pipeline-для-openvino).
|
||||
- Переключить русский CPU-дефолт на победителя сравнительного GigaAM-бенчмарка.
|
||||
- Предупреждать пользователя при `--device openvino-cpu` для файлов >30 мин.
|
||||
|
||||
**Приоритет:** высокий — проблема затрагивает текущий OpenVINO CPU-путь, а
|
||||
целевая аудитория включает ноутбуки с Intel Core i5 11-го поколения. GigaAM v3
|
||||
остаётся рабочей явной альтернативой для русского, но выбор безопасного
|
||||
auto/default требует сравнительного бенчмарка.
|
||||
|
||||
---
|
||||
|
||||
### Интеллектуальное чанкование длинных файлов по паузам
|
||||
|
||||
**Что:** Резать длинные файлы на чанки (~90 с) не по фиксированной сетке, а по ближайшей тишине: `ffmpeg -af silencedetect=noise=-40dB:d=0.5` → парсинг stderr → выбор точки разреза в окне ±30 с вокруг целевой границы (с минимальным зазором между разрезами, чтобы не получить нулевые чанки).
|
||||
|
||||
**Почему:** Разрез посреди слова/фразы портит распознавание на границах чанков; разрез по паузе — нет. Потенциально смягчает класс ошибок Whisper medium на длинных файлах (потеря хвоста, блоки повторов — см. пункт выше): короткие чанки не дают декодеру «уплыть».
|
||||
|
||||
**Источник:** референсная реализация Parakeet-сервера (Flask, OpenAI-совместимый API), лежавшая в репо как `app.py` в период эксперимента ADR-005/006 (апрель 2026); удалена при чистке 2026-07-09 — рабочие константы: порог -40dB, мин. тишина 0.5 с, окно поиска 30 с, мин. зазор 5 с.
|
||||
|
||||
**Tradeoff:** дополнительный проход ffmpeg по всему файлу (silencedetect) перед транскрипцией; для часового файла — десятки секунд.
|
||||
|
||||
---
|
||||
|
||||
### Качественный pipeline для OpenVINO (temperature fallback + фильтры)
|
||||
|
||||
**Что:** Реализовать temperature fallback, compression_ratio и log_prob фильтры поверх OpenVINO GenAI WhisperPipeline. Эвристики — логика на Python (~50-100 строк), не зависящая от inference engine.
|
||||
|
||||
**Почему:** Дать Intel Arc / AMD GPU и AMD CPU то же качество, что сейчас есть только у CUDA-пользователей через CTranslate2. Полностью описано в [docs/gpu.md](gpu.md#качественный-pipeline-для-openvino).
|
||||
|
||||
---
|
||||
|
||||
## Структура транскрипта (конспекты и MoM)
|
||||
|
||||
Источник раздела: внешнее сравнение локального транскрипта (medium,
|
||||
openvino-cpu, запись 25:59) с облачным сервисом Hypescribe, критерий —
|
||||
пригодность как сырья для конспекта и протокола встречи (GPT-ревью,
|
||||
2026-07-10). Итог: по смыслу локальная модель почти равна облаку
|
||||
(6.5/10 против 7/10), главный разрыв — **не качество распознавания,
|
||||
а структура**: разделение говорящих (2/10 против 8/10) и нарезка на
|
||||
реплики. Приоритеты ревьюера: 1) смысл, 2) спикеры, 3) техтермины,
|
||||
4) разбивка на фразы, 5) таймкоды. Вывод: локальная диаризация +
|
||||
словарь терминов закрывают потребность в облачном сервисе для
|
||||
внутренних встреч.
|
||||
|
||||
Сознательно вне ядра CLI (максимум — рецепт в README): второй проход
|
||||
LLM для чистки текста, сопоставление Speaker N с именами — это работа
|
||||
поверх готового транскрипта.
|
||||
|
||||
### Диаризация — разделение говорящих
|
||||
|
||||
**Что:** Опциональный пост-процессинг (не четвёртый бэкенд): сегменты
|
||||
уже несут таймкоды; диаризация даёт интервалы «кто когда говорил»;
|
||||
merge по перекрытию интервалов; formatter ломает абзац на смене спикера
|
||||
и подписывает `Speaker 1:`. Ставится как extra:
|
||||
`uv sync --extra diarization`.
|
||||
|
||||
**Почему:** Без спикеров MoM не собрать — это ключевой разрыв с облаком
|
||||
по внешнему ревью, и никакое качество распознавания его не компенсирует.
|
||||
Заодно естественно решает «разбивку на реплики» (приоритет №4).
|
||||
|
||||
**Варианты реализации (ключевое решение, нужен ADR):**
|
||||
|
||||
- **sherpa-onnx** — диаризация целиком на onnxruntime (сегментация
|
||||
pyannote в ONNX + спикер-эмбеддинги), без torch, в духе нашего
|
||||
onnx-стека и «no cloud, no API keys».
|
||||
- **pyannote.audio** — стандарт качества, но тянет torch и требует
|
||||
HF-токен с принятием лицензии моделей — трение с духом проекта.
|
||||
|
||||
**Уточнить перед запуском:** качество обоих вариантов на русской речи
|
||||
и перекрывающихся репликах; скорость на CPU (диаризация — второй проход
|
||||
по всему аудио); лицензии моделей сегментации/эмбеддингов.
|
||||
|
||||
---
|
||||
|
||||
### Ручка нарезки абзацев в formatter
|
||||
|
||||
**Что:** «Минутные простыни» в транскрипте — не свойство модели, а наши
|
||||
константы группировки `_PAUSE_THRESHOLD_S = 2.0` / `_MAX_PARAGRAPH_S =
|
||||
60.0` в `formatter.py` (сырых сегментов много: 23-минутная запись — 360
|
||||
сегментов, ~4 с на реплику). Вынести в опцию/конфиг или уменьшить
|
||||
дефолт.
|
||||
|
||||
**Почему откладывается:** при диаризации абзацы будут ломаться по смене
|
||||
спикера естественно — сначала решить с диаризацией, чтобы не делать
|
||||
ручку, которая устареет.
|
||||
|
||||
---
|
||||
|
||||
### Словарь замен технических терминов — запасной план
|
||||
|
||||
**Что:** Пост-обработка текста сегментов словарём замен по границам слов
|
||||
(`CSW → CSV`, `софтп → SFTP`, `ямлик → YAML`, `Spark и Scale → Spark
|
||||
SQL`), словарь пользовательский в `.transcriber.toml`.
|
||||
|
||||
**Почему запасной:** это тот же класс ошибок, что «кириллизация латиницы
|
||||
и искажение имён» из [ADR-006](adr/006-onnx-asr-backend.md), и первым
|
||||
его должен попробовать закрыть контекстный декодер GigaAM v3 RNN-T
|
||||
(первый пункт бэклога). Заводить словарь — только если бенчмарк RNN-T
|
||||
термины не вытянет.
|
||||
|
||||
---
|
||||
|
||||
## Авто-детект и UX
|
||||
|
||||
### Профили намерения вместо выбора модели
|
||||
|
||||
**Что:** Вместо `--model gigaam-v3-e2e-rnnt` пользователь выбирает намерение —
|
||||
условные `ru-fast`, `ru-readable`, `mixed`, — а проект разворачивает его в пару
|
||||
модель + квантизация с учётом устройства.
|
||||
|
||||
**Почему:** Имена onnx-моделей ничего не говорят о том, что получит
|
||||
пользователь, и различие «поддерживаемая модель ≠ рекомендуемая» через них не
|
||||
выражается.
|
||||
|
||||
**Почему откладывается:** профиль осмыслен, когда известно, какой профиль чем
|
||||
закрывается — то есть после сравнительной оценки. Введение понятия раньше
|
||||
данных закрепит догадку в интерфейсе. Источник:
|
||||
[спека «Onnx-каталог, Python 3.13 и границы версий»](specs/2026-08-11-onnx-model-catalog.md).
|
||||
|
||||
---
|
||||
|
||||
### Включение `onnx` в `--device auto`
|
||||
|
||||
**Что:** После периода стабилизации `--device onnx` (несколько недель production-использования без жалоб) — рассмотреть включение в auto-detect chain.
|
||||
|
||||
**Порядок в chain (предложение):** CUDA → onnx (если CPU x86_64) → OpenVINO → CPU.
|
||||
|
||||
**Почему откладывается:** политика experimental backend — не сюрпризить существующих пользователей до накопления опыта. Источник: [ADR-006](adr/006-onnx-asr-backend.md#решение).
|
||||
|
||||
---
|
||||
|
||||
## Отклонённые направления
|
||||
|
||||
*(пока пусто — добавлять сюда то, что попробовали и решили не делать, с причиной)*
|
||||
+181
-20
@@ -37,7 +37,7 @@ OpenVINO ускоряет inference на x86 процессорах (Intel и AM
|
||||
| tiny | OpenVINO/whisper-tiny-int8-ov | — |
|
||||
| base | — | OpenVINO/whisper-base-fp16-ov |
|
||||
| small | OpenVINO/whisper-small-int8-ov | — |
|
||||
| medium | OpenVINO/whisper-medium-int8-ov | — |
|
||||
| medium | OpenVINO/whisper-medium-int8-ov | OpenVINO/whisper-medium-fp16-ov |
|
||||
| large-v3 | OpenVINO/whisper-large-v3-int8-ov | OpenVINO/whisper-large-v3-fp16-ov |
|
||||
|
||||
### Результаты тестирования OpenVINO
|
||||
@@ -50,7 +50,7 @@ OpenVINO ускоряет inference на x86 процессорах (Intel и AM
|
||||
|---|---|---|---|
|
||||
| Intel Ultra 7 255H | **122с** | **411с** | — |
|
||||
| AMD Ryzen 7 8845H | 185с | 416с | 734с |
|
||||
| Intel i7 (WSL2) | 171-205с | — | 658с |
|
||||
| Intel i7-11800H (WSL2) | 171-205с | — | 658с |
|
||||
|
||||
**Ускорение vs CPU float32:** **3-6x** в зависимости от CPU.
|
||||
|
||||
@@ -77,6 +77,113 @@ OpenVINO ускоряет inference на x86 процессорах (Intel и AM
|
||||
- **medium** — для повседневного использования и обработки ИИ. Ключевые термины верные, единичные ляпы не влияют на смысл конспекта. Оптимальный баланс скорости и качества.
|
||||
- **large-v3** — для важных записей, где нужна дословная точность. Лучшая пунктуация и связность. На OpenVINO (416с) быстрее, чем medium на чистом CPU (734с) — лучшее качество при выше скорости.
|
||||
|
||||
## CPU бэкенд (CTranslate2 / faster-whisper)
|
||||
|
||||
При `--device cpu` используется faster-whisper на основе CTranslate2. Этот бэкенд медленнее
|
||||
OpenVINO, но обеспечивает **лучшее качество** благодаря развитому pipeline декодирования.
|
||||
|
||||
### Рекомендуемый compute_type: `int8_float32`
|
||||
|
||||
```bash
|
||||
transcribe meeting.mp4 --device cpu --compute-type int8_float32
|
||||
```
|
||||
|
||||
`int8_float32` — int8 квантизация весов с float32 аккумулятором. Даёт **1.5x ускорение**
|
||||
vs float32 при сопоставимом качестве (протестировано на русском языке с техтерминами).
|
||||
|
||||
| compute_type | Скорость* | Качество | Когда использовать |
|
||||
|---|---|---|---|
|
||||
| `int8_float32` | **~460с** | Отлично | **Рекомендуется** — лучший баланс |
|
||||
| `float32` | ~880с | Отлично (эталон) | Если важна максимальная точность |
|
||||
| `int8` | быстрее | Хорошо, но бывают галлюцинации | Не рекомендуется для длинных записей |
|
||||
|
||||
\* Замеры на AMD Ryzen 7 8845H, medium, запись 14:41, 8 потоков.
|
||||
|
||||
### Оптимизация потоков
|
||||
|
||||
CTranslate2 по умолчанию использует 4 потока. На многоядерных CPU рекомендуется
|
||||
задать число потоков равным числу **физических ядер** (не виртуальных):
|
||||
|
||||
```bash
|
||||
transcribe meeting.mp4 --device cpu --compute-type int8_float32 --threads 8
|
||||
```
|
||||
|
||||
**Замеры (Intel i7-11800H, 8 ядер / 16 потоков, medium int8_float32, 16 мин файл):**
|
||||
|
||||
| --threads | Время | Ускорение |
|
||||
|---|---|---|
|
||||
| 0 (дефолт = 4) | 320с | baseline |
|
||||
| 8 (физ. ядра) | **277с** | **+13%** |
|
||||
|
||||
SMT/Hyper-Threading не помогает — 16 потоков на 8-ядерном CPU медленнее, чем 8.
|
||||
|
||||
### Почему CPU бэкенд качественнее OpenVINO
|
||||
|
||||
При одной и той же модели (medium) CTranslate2 даёт заметно лучше распознавание,
|
||||
чем OpenVINO. Разница **не в квантизации**, а в pipeline декодирования:
|
||||
|
||||
| Механизм | CTranslate2 (faster-whisper) | OpenVINO GenAI |
|
||||
|---|---|---|
|
||||
| Temperature fallback | ✅ до 5 попыток с ростом temperature | ❌ один проход |
|
||||
| Фильтр по compression_ratio | ✅ отсекает повторы | ❌ нет |
|
||||
| Фильтр по log_prob | ✅ отсекает неуверенные сегменты | ❌ нет |
|
||||
| no_speech_threshold | ✅ детекция тишины | ❌ нет |
|
||||
| VAD (Silero) | ✅ опционально | ❌ нет |
|
||||
| condition_on_previous_text | ✅ с умным сбросом | ⚠️ базовый |
|
||||
|
||||
Эти эвристики критичны для русской разговорной речи с паузами и перебивками.
|
||||
|
||||
### Выбор между OpenVINO и CPU
|
||||
|
||||
| Сценарий | Рекомендация |
|
||||
|---|---|
|
||||
| Быстрый черновой транскрипт | `--device openvino-cpu` (int8, ~240с) |
|
||||
| Качественный транскрипт для суммаризации | `--device cpu --compute-type int8_float32` (~460с) |
|
||||
| Максимальная точность | `--device cpu --compute-type float32` (~880с) |
|
||||
|
||||
## CUDA: compute_type и качество
|
||||
|
||||
На NVIDIA GPU дефолт — `float16`, и для большинства случаев это оптимальный выбор.
|
||||
|
||||
### Результаты тестирования compute_type на GPU (RTX 3060 Laptop, 6 GB)
|
||||
|
||||
**Скорость (файл 16 мин, русский язык):**
|
||||
|
||||
| Модель | float16 | int8_float32 | int8_float16 |
|
||||
|---|---|---|---|
|
||||
| medium | **53с** (~18x) | 64с (~15x) | 54с (~18x) |
|
||||
| large-v3 | **86с** (~11x) | 113с (~8.5x) | 125с (~7.7x) |
|
||||
|
||||
**Скорость (файл 46 мин, русский язык):**
|
||||
|
||||
| Модель | float16 |
|
||||
|---|---|
|
||||
| medium | **127с** (~22x реалтайм) |
|
||||
| large-v3 | 271с (~10x реалтайм) |
|
||||
|
||||
**Качество (файл 16 мин):**
|
||||
|
||||
| Модель | float16 | int8_float32 | int8_float16 |
|
||||
|---|---|---|---|
|
||||
| medium | **Отлично** | **Отлично** (≈float16) | Хорошо (1-2 ошибки) |
|
||||
| large-v3 | Хорошо (ед. галлюцинации) | **Плохо** (повтор "Ага" ×18) | **Неприемлемо** (мусор, иероглифы) |
|
||||
|
||||
**Качество (файл 46 мин):**
|
||||
|
||||
| Модель | float16 | Проблемы |
|
||||
|---|---|---|
|
||||
| medium | **Отлично** | Единичные ляпы ("email" вместо "ML"), стабилен |
|
||||
| large-v3 | **Плохо** | Галлюцинации на 3+ языках (китайский, арабский), повторы фраз, "Аминь" вместо "Угу" |
|
||||
|
||||
### Выводы по CUDA compute_type
|
||||
|
||||
- **`float16` — оптимальный дефолт**: самый быстрый и стабильный
|
||||
- **`int8_float32` не дал выигрыша**: на 20% медленнее float16, качество для medium сопоставимо, для large-v3 — деградация
|
||||
- **`int8_float16`**: по скорости ≈ float16 для medium, но large-v3 даёт мусор
|
||||
- **`int8`**: рискует галлюцинациями на large-v3 и длинных записях (повтор фраз ×25)
|
||||
- **medium устойчивее к квантизации**, чем large-v3 — все варианты compute_type дают приемлемый результат
|
||||
- **large-v3 на длинных записях (>20 мин)**: галлюцинации даже с float16 — medium надёжнее
|
||||
|
||||
## Настройка по платформам
|
||||
|
||||
### Linux / WSL2 (x86_64)
|
||||
@@ -98,45 +205,99 @@ winget install -e --id Nvidia.CUDA --version 12.9 # требует запус
|
||||
|
||||
## Совместимость GPU
|
||||
|
||||
| GPU | VRAM | medium float16 | large-v3 float16 | Рекомендация |
|
||||
|-----|------|---------------|-----------------|--------------|
|
||||
| RTX 3060 | 6 GB | ✅ | ✅ | medium float16 (дефолт) |
|
||||
| RTX 4050 | 6 GB | ✅ | ✅ | medium float16 |
|
||||
| Quadro M3000M | 4 GB | ✅ | ⚠️ tight | medium float16 или int8 |
|
||||
| GPU | VRAM | CC | medium float16 | large-v3 float16 | Рекомендация |
|
||||
|-----|------|----|---------------|-----------------|--------------|
|
||||
| RTX 3060 | 6 GB | 8.6 | ✅ | ✅ | medium float16 (дефолт) |
|
||||
| RTX 4050 | 6 GB | 8.9 | ✅ | ✅ | medium float16 |
|
||||
| Quadro M3000M | 4 GB | 5.0 | ❌ | ❌ | `--device cpu` или `openvino` |
|
||||
|
||||
> **Quadro M3000M и другие GPU с CC < 7.0 (Maxwell, Pascal)**: CUDA не работает —
|
||||
> float16 требует CC ≥ 7.0, float32 падает с ошибкой (CTranslate2 4.x не включает
|
||||
> sm_50/sm_60 в пребилды). Используйте `--device cpu --compute-type int8_float32`
|
||||
> или `--device openvino`.
|
||||
|
||||
## Ожидаемая скорость
|
||||
|
||||
Замеры на RTX 3060 Laptop (6 GB) и Intel CPU (WSL2):
|
||||
|
||||
| Конфигурация | 16 мин файл | 42 мин файл | Отн. скорость |
|
||||
| Конфигурация | 16 мин файл | 46 мин файл | Отн. скорость |
|
||||
|-------------|-------------|-------------|---------------|
|
||||
| GPU + medium float16 | ~35с | ~133с | ~19x реалтайм |
|
||||
| GPU + large-v3 float16 | ~90с | ~350с | ~7x реалтайм |
|
||||
| GPU + medium float16 | **53с** | **127с** | **~18-22x реалтайм** |
|
||||
| GPU + large-v3 float16 | 86с | 271с | ~10-11x реалтайм |
|
||||
| GPU + medium int8_float32 | 64с | — | ~15x реалтайм |
|
||||
| GPU + medium int8_float16 | 54с | — | ~18x реалтайм |
|
||||
| **OpenVINO + small int8** | **93с** | **153с** | **~10-16x реалтайм** |
|
||||
| **OpenVINO + medium int8** | **171-205с** | **413с** | **~4-6x реалтайм** |
|
||||
| **OpenVINO + large-v3 fp16** | **416с** | — | **~2.3x реалтайм** |
|
||||
| **CPU + medium int8_float32** | **~460с*** | — | **~2x реалтайм** |
|
||||
| CPU + medium float32 | 658с (11 мин) | ~26 мин | ~1.5x реалтайм |
|
||||
| CPU + large-v3 int8 | 839с (14 мин) | ~37 мин | ~1:1 реалтайм |
|
||||
|
||||
\* Замер CPU int8_float32 на AMD Ryzen 7 8845H (8 потоков), файл 14:41.
|
||||
|
||||
## Результаты тестирования качества
|
||||
|
||||
Тесты проведены на реальных записях рабочих созвонов (русский язык, технические термины:
|
||||
SQL, PostgreSQL, Greenplum, Airflow, ClickHouse, Docker, CDR, GTP, MAP).
|
||||
|
||||
| Конфигурация | Качество (длинная запись, 42 мин) | Проблемы |
|
||||
|---|---|---|
|
||||
| large-v3 int8 GPU | Плохо | Галлюцинации (фразы ×25), потеря контента |
|
||||
| large-v3 float16 GPU | Отлично | — |
|
||||
| medium float16 GPU | Хорошо | Редкие мелкие ляпы в терминах |
|
||||
| medium float32 CPU | Хорошо | Сопоставимо с large-v3 int8, без галлюцинаций |
|
||||
| large-v3 int8 CPU | Хорошо | Без галлюцинаций (на коротких файлах) |
|
||||
| Конфигурация | Качество (короткие, ≤16 мин) | Качество (длинные, >40 мин) | Проблемы |
|
||||
|---|---|---|---|
|
||||
| medium float16 GPU | **Отлично** | **Отлично** | Единичные ляпы в терминах ("email" вместо "ML") |
|
||||
| medium int8_float32 GPU | **Отлично** | — | Качество ≈ float16, но на 20% медленнее |
|
||||
| medium int8_float16 GPU | Хорошо | — | 1-2 ошибки |
|
||||
| large-v3 float16 GPU | Хорошо | **Плохо** | Галлюцинации на длинных записях (мусор на 3+ языках) |
|
||||
| large-v3 int8_float32 GPU | **Плохо** | — | Повтор фраз ("Ага" ×18), искажения |
|
||||
| large-v3 int8_float16 GPU | **Неприемлемо** | — | Мусор, китайские/арабские символы, потеря текста |
|
||||
| large-v3 int8 GPU | **Плохо** | **Плохо** | Галлюцинации (фразы ×25), потеря контента |
|
||||
| medium int8_float32 CPU | **Отлично** | — | Качество ≈ float32, на уровне облачных сервисов |
|
||||
| medium float32 CPU | **Отлично** | Хорошо | Эталон качества |
|
||||
| large-v3 int8 CPU | Хорошо | — | Без галлюцинаций (на коротких файлах) |
|
||||
|
||||
### Ключевые выводы
|
||||
|
||||
1. **Указание языка (`--language ru`) критично** — auto-detect может ошибиться и выдать мусор
|
||||
2. **float16/float32 стабильнее int8** — особенно на записях >20 минут
|
||||
3. **medium + float16 на GPU — лучший баланс** скорости и качества для повседневного использования
|
||||
4. **large-v3 + float16 на GPU** — для максимального качества важных записей
|
||||
2. **medium + float16 на GPU — лучший баланс** скорости и качества для любых записей
|
||||
3. **large-v3 ненадёжен на длинных записях (>20 мин)** — галлюцинации даже с float16;
|
||||
medium стабильнее на любой длине
|
||||
4. **medium устойчив к квантизации** — все варианты compute_type дают приемлемый результат;
|
||||
large-v3 деградирует катастрофически при любом int8
|
||||
5. **CPU: int8_float32 — лучший баланс** — 1.5x быстрее float32 при том же качестве
|
||||
6. **OpenVINO быстрее, но CPU бэкенд качественнее** — разница в pipeline декодирования
|
||||
(temperature fallback, фильтры галлюцинаций), а не в квантизации
|
||||
|
||||
## Потенциальные направления развития
|
||||
|
||||
### Качественный pipeline для OpenVINO
|
||||
|
||||
Сейчас качественный pipeline декодирования (temperature fallback, фильтры галлюцинаций,
|
||||
VAD) доступен только через CTranslate2, т.е. на CPU и NVIDIA CUDA. Пользователи Intel Arc
|
||||
и AMD Radeon получают скорость OpenVINO, но без защиты от галлюцинаций.
|
||||
|
||||
**Решение**: реализовать temperature fallback, compression_ratio и log_prob фильтры
|
||||
поверх OpenVINO GenAI WhisperPipeline. Эти эвристики — логика на Python (~50-100 строк),
|
||||
не зависящая от inference engine. Это даст Intel Arc / AMD GPU то же качество,
|
||||
что сейчас есть только у CUDA-пользователей.
|
||||
|
||||
### CUDA: int8_float32 / int8_float16 — протестировано
|
||||
|
||||
На CPU `int8_float32` показал качество на уровне float32 при 1.5x ускорении.
|
||||
На GPU (RTX 3060) результат другой: для **medium** int8_float32 даёт сопоставимое
|
||||
качество, но на 20% медленнее float16 — выигрыша нет. Для **large-v3** любой int8
|
||||
вариант (включая int8_float32) вызывает галлюцинации — float32 аккумулятор не спасает
|
||||
глубокую модель на GPU. Вывод: **float16 остаётся оптимальным дефолтом для CUDA**.
|
||||
|
||||
### NVIDIA Parakeet TDT — проверено, отклонено
|
||||
|
||||
Оценивалась гипотеза: multilingual Parakeet TDT 0.6B v3 через `onnx-asr` может обойти
|
||||
Whisper medium int8 на x86 CPU. На двух 15-минутных отрывках реальных русских ИТ-встреч
|
||||
(Intel i7-11800H) Parakeet показал **2.7x преимущество по скорости** и **-35% RAM**, но
|
||||
**качество ниже** — систематическая редукция тихих реплик менти до `Mm-hmm`, вставки
|
||||
кусков польского/испанского, сильное искажение ИТ-терминов. Попытка тюнинга Silero VAD
|
||||
threshold не помогла — проблема inherent для модели на тихой русской речи.
|
||||
|
||||
Код бэкенда не вмержен в master; эксперимент сохранён в ветке
|
||||
`feature/parakeet-backend`. Подробности и когда стоит пересмотреть —
|
||||
[ADR-005](adr/005-parakeet-evaluation.md).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Обновления движков и моделей на 2026-08-10
|
||||
|
||||
Исследование выполнено по первичным источникам: официальным release notes, changelog, PyPI и model cards Hugging Face. Текущее состояние проекта зафиксировано в [`pyproject.toml`](../../pyproject.toml) и [`uv.lock`](../../uv.lock).
|
||||
|
||||
## Краткий вывод
|
||||
|
||||
В проекте уже используется актуальный `faster-whisper` 1.2.1, однако связанные движки и модельный каталог можно обновить. Наиболее полезная последовательность: согласованно поднять OpenVINO и OpenVINO GenAI до 2026.3, разрешить `onnx-asr` 0.12 и проверить VAD, обновить CTranslate2 до 4.8.1, затем добавить `large-v3-turbo` для FasterWhisper и OpenVINO. ONNX Runtime 1.28 нельзя безусловно фиксировать, пока проект поддерживает Python 3.10, поскольку эта версия ORT требует Python 3.11 или новее ([ONNX Runtime 1.28.0 на PyPI](https://pypi.org/project/onnxruntime/1.28.0/)).
|
||||
|
||||
## Движки
|
||||
|
||||
| Компонент | Сейчас в проекте | Актуальная стабильная версия | Существенные изменения и риски |
|
||||
|---|---:|---:|---|
|
||||
| faster-whisper | 1.2.1 | 1.2.1 | Проект уже на последнем релизе. В 1.2.1 обновлён Silero VAD до v6, доработаны retry Hugging Face Hub и `clip_timestamps`; в 1.2.0 добавлена поддержка `distil-large-v3.5` ([официальные releases](https://github.com/SYSTRAN/faster-whisper/releases), [PyPI](https://pypi.org/project/faster-whisper/)). Для актуального GPU-стека документация указывает CUDA 12 и cuDNN 9; для старых комбинаций требуются специальные версии CTranslate2 ([официальная установка](https://github.com/SYSTRAN/faster-whisper#gpu)). |
|
||||
| CTranslate2 | 4.7.1 | 4.8.1 | В 4.8.0 `PACKED_GEMM` включён по умолчанию для Intel MKL; 4.8.1 исправляет heap overflow при загрузке модели и аварийное деление на ноль в Whisper `align()` при отсутствии кадров ([официальный changelog](https://github.com/OpenNMT/CTranslate2/blob/master/CHANGELOG.md), [релиз 4.8.1](https://github.com/OpenNMT/CTranslate2/releases/tag/v4.8.1), [PyPI](https://pypi.org/project/ctranslate2/)). Публичного breaking API для используемого пути не заявлено, но из-за нативных библиотек нужно проверить Windows CPU, Linux CUDA и проектный CUDA bootstrap. Требование cuDNN 9 появилось в CTranslate2 4.5.0 ([changelog](https://github.com/OpenNMT/CTranslate2/blob/master/CHANGELOG.md#450)). |
|
||||
| OpenVINO | 2026.0.0 | 2026.3.0 | В 2026.3 добавлены общий `ASRPipeline`, Qwen3-ASR и метрики задержки стадий распознавания; в ветке 2026.x также появились word-level timestamps и определённый язык в результатах Whisper ([официальные release notes](https://docs.openvino.ai/2026/about-openvino/release-notes-openvino.html), [PyPI](https://pypi.org/project/openvino/)). Начиная с 2026.0 удалена поддержка устаревшего stateless Whisper decoder, поэтому модели должны быть stateful; та же версия требует минимум AVX2, использует manylinux_2_28 и больше не поддерживает CentOS 7 ([release notes 2026.0](https://docs.openvino.ai/2026/about-openvino/release-notes-openvino.html#openvino-2026-0-0)). |
|
||||
| openvino-genai | 2026.0.0.0 | 2026.3.0.0 | OpenVINO GenAI необходимо обновлять вместе с OpenVINO: официальная документация требует совпадения `major.minor.patch` у OpenVINO, OpenVINO Tokenizers и GenAI, иначе возможны ABI/import errors. PyPI-пакеты собраны с `_GLIBCXX_USE_CXX11_ABI=0`, а архивы C++ — с ABI=1, поэтому смешивать PyPI GenAI с OpenVINO из C++ archive нельзя ([официальная страница PyPI](https://pypi.org/project/openvino-genai/), [официальные releases](https://github.com/openvinotoolkit/openvino.genai/releases)). |
|
||||
| onnx-asr | 0.11.0 | 0.12.0 | Обновление сейчас **явно заблокировано** зависимостью `onnx-asr[cpu,hub]>=0.11.0,<0.12.0` в [`pyproject.toml`](../../pyproject.toml). В 0.12 добавлены GigaAM Multilingual CTC/Large CTC, свёрточные ONNX preprocessors с ускорением CUDA EP и ORT fallback; также исправлены сегменты нулевой длины после VAD, что напрямую относится к используемому проектом `.with_vad()` ([релиз 0.12.0](https://github.com/istupakov/onnx-asr/releases/tag/v0.12.0), [официальные release notes](https://istupakov.github.io/onnx-asr/release-notes/), [PyPI](https://pypi.org/project/onnx-asr/)). Breaking Python API не объявлен, но проект должен проверить загрузку квантованных моделей, VAD и форму возвращаемых сегментов. |
|
||||
| ONNX Runtime | 1.24.3 | 1.28.0 | В 1.28 обновлены ONNX и protobuf и включены security fixes, в том числе проверка границ в `WhisperDecoderSubgraph` ([официальный релиз 1.28.0](https://github.com/microsoft/onnxruntime/releases/tag/v1.28.0), [PyPI](https://pypi.org/project/onnxruntime/1.28.0/)). Версия 1.28 требует Python 3.11+, тогда как проект допускает Python 3.10, поэтому нужен условный lock/constraint либо осознанное повышение минимальной версии Python ([метаданные PyPI 1.28.0](https://pypi.org/project/onnxruntime/1.28.0/)). Для GPU-пакетов ORT 1.27+ используется CUDA 13.0 и cuDNN 9; major-версии CUDA и cuDNN должны совпадать с runtime ([официальная матрица CUDA EP](https://onnxruntime.ai/docs/execution-providers/CUDA-ExecutionProvider.html#requirements)). |
|
||||
|
||||
## Модели и варианты
|
||||
|
||||
### Whisper large-v3-turbo
|
||||
|
||||
`whisper-large-v3-turbo` — мультиязычная модель на 99 языков с 809 млн параметров вместо 1550 млн у `large-v3`; число decoder layers сокращено с 32 до 4, что заметно ускоряет вывод ценой небольшой потери качества ([официальная model card OpenAI](https://huggingface.co/openai/whisper-large-v3-turbo)). Это наиболее полезный новый вариант для русского и смешанного аудио.
|
||||
|
||||
FasterWhisper уже содержит стандартные aliases `large-v3-turbo` и `turbo` в собственной таблице моделей ([официальный `utils.py`](https://github.com/SYSTRAN/faster-whisper/blob/master/faster_whisper/utils.py)); готовая CTranslate2-конверсия опубликована как [`dropbox-dash/faster-whisper-large-v3-turbo`](https://huggingface.co/dropbox-dash/faster-whisper-large-v3-turbo). Проект использует собственную ограниченную таблицу aliases, поэтому модель нужно добавить явно либо перейти на стандартное разрешение имён FasterWhisper ([текущая реализация проекта](../../src/local_transcriber/backends/faster_whisper.py)).
|
||||
|
||||
Для OpenVINO опубликованы официальные варианты [`whisper-large-v3-turbo-fp16-ov`](https://huggingface.co/OpenVINO/whisper-large-v3-turbo-fp16-ov) и [`whisper-large-v3-turbo-int8-ov`](https://huggingface.co/OpenVINO/whisper-large-v3-turbo-int8-ov). Их model cards требуют OpenVINO 2026.1 или новее, поэтому текущий OpenVINO 2026.0 недостаточен; после согласованного обновления до 2026.3 можно добавить общий alias `turbo` для FasterWhisper и OpenVINO.
|
||||
|
||||
### GigaAM Multilingual
|
||||
|
||||
`onnx-asr` 0.12 добавляет модели `gigaam-multilingual-ctc` и `gigaam-multilingual-large-ctc` ([официальный релиз 0.12.0](https://github.com/istupakov/onnx-asr/releases/tag/v0.12.0), [документация использования](https://istupakov.github.io/onnx-asr/usage/)). Конвертированные репозитории опубликованы как [`gigaam-multilingual-ctc-onnx`](https://huggingface.co/istupakov/gigaam-multilingual-ctc-onnx) и [`gigaam-multilingual-large-ctc-onnx`](https://huggingface.co/istupakov/gigaam-multilingual-large-ctc-onnx), исходная модель — [`ai-sage/GigaAM-Multilingual`](https://huggingface.co/ai-sage/GigaAM-Multilingual). Они полезны для смешанной речи на поддерживаемых пяти языках, но требуют снятия ограничения `<0.12.0`.
|
||||
|
||||
### GigaAM v3 E2E
|
||||
|
||||
Текущий ONNX-репозиторий GigaAM v3 содержит не только CTC/RNNT, но и E2E-варианты с пунктуацией и нормализацией текста ([официальная model card `istupakov/gigaam-v3-onnx`](https://huggingface.co/istupakov/gigaam-v3-onnx)). E2E-вариант может улучшить читаемость русского текста, но меняет семантику вывода относительно текущего `gigaam-v3-ctc`; перед заменой default нужны сравнительные тесты транскрипции и форматирования.
|
||||
|
||||
### Distil-Whisper large-v3.5
|
||||
|
||||
`distil-large-v3.5` поддерживается faster-whisper начиная с 1.2.0 ([официальные releases](https://github.com/SYSTRAN/faster-whisper/releases)); доступны [исходная модель](https://huggingface.co/distil-whisper/distil-large-v3.5) и [CTranslate2-конверсия](https://huggingface.co/distil-whisper/distil-large-v3.5-ct2). Модель предназначена только для английского языка, поэтому она не подходит как русский default, но может быть отдельной English-only опцией.
|
||||
|
||||
### Qwen3-ASR как R&D
|
||||
|
||||
OpenVINO 2026.3 добавляет раннюю поддержку Qwen3-ASR через новый общий `ASRPipeline` ([официальные release notes 2026.3](https://docs.openvino.ai/2026/about-openvino/release-notes-openvino.html#openvino-2026-3-0)). Текущий backend проекта построен вокруг `WhisperPipeline` и Whisper-совместимых сегментов ([текущая реализация](../../src/local_transcriber/backends/openvino.py)), поэтому Qwen3-ASR не является drop-in обновлением модели: это отдельная R&D-задача с новым интерфейсом, модельным контрактом и тестами качества.
|
||||
|
||||
## Приоритет действий
|
||||
|
||||
1. Согласованно обновить `openvino` до 2026.3.0 и `openvino-genai` до 2026.3.0.0, не смешивая источники сборок; проверить существующие stateful Whisper-модели, CPU/GPU и минимальные платформенные требования ([совместимость GenAI](https://pypi.org/project/openvino-genai/), [release notes OpenVINO](https://docs.openvino.ai/2026/about-openvino/release-notes-openvino.html)).
|
||||
2. Убрать блокирующее ограничение `<0.12.0`, поднять `onnx-asr` до 0.12.0 и прогнать интеграционные тесты VAD, сегментов и квантованных GigaAM-моделей ([релиз 0.12.0](https://github.com/istupakov/onnx-asr/releases/tag/v0.12.0)).
|
||||
3. Обновить CTranslate2 с 4.7.1 до 4.8.1 и проверить Windows CPU/Linux CUDA, включая CUDA bootstrap ([changelog](https://github.com/OpenNMT/CTranslate2/blob/master/CHANGELOG.md)).
|
||||
4. Добавить alias `turbo` и репозитории `large-v3-turbo` для FasterWhisper и OpenVINO после обновления OpenVINO ([OpenAI model card](https://huggingface.co/openai/whisper-large-v3-turbo), [OpenVINO FP16](https://huggingface.co/OpenVINO/whisper-large-v3-turbo-fp16-ov), [OpenVINO INT8](https://huggingface.co/OpenVINO/whisper-large-v3-turbo-int8-ov)).
|
||||
5. Не фиксировать ONNX Runtime 1.28 для всех окружений, пока поддерживается Python 3.10; сначала выбрать условные зависимости либо официально поднять Python floor ([PyPI 1.28.0](https://pypi.org/project/onnxruntime/1.28.0/)).
|
||||
6. Рассматривать GigaAM Multilingual, GigaAM v3 E2E и Qwen3-ASR как отдельные эксперименты с качеством и совместимостью, а `distil-large-v3.5` — только как English-only профиль ([GigaAM Multilingual](https://huggingface.co/ai-sage/GigaAM-Multilingual), [GigaAM v3 ONNX](https://huggingface.co/istupakov/gigaam-v3-onnx), [OpenVINO 2026.3](https://docs.openvino.ai/2026/about-openvino/release-notes-openvino.html#openvino-2026-3-0), [Distil-Whisper](https://huggingface.co/distil-whisper/distil-large-v3.5)).
|
||||
@@ -0,0 +1,160 @@
|
||||
# Onnx-каталог, Python 3.13 и границы версий
|
||||
|
||||
## Проблема
|
||||
|
||||
Смешанная русско-английская речь и отсутствие пунктуации у `gigaam-v3-ctc` —
|
||||
известные ограничения onnx-пути ([ADR-006](../adr/006-onnx-asr-backend.md)). В
|
||||
`onnx-asr` 0.12 появились модели, которые их адресуют, но обновление
|
||||
заблокировано ограничением `onnx-asr[cpu,hub]>=0.11.0,<0.12.0` в
|
||||
`pyproject.toml`.
|
||||
|
||||
Состояние движков и первичные источники —
|
||||
[исследование обновлений](../research/2026-08-10-engine-model-updates.md).
|
||||
|
||||
## Что делаем
|
||||
|
||||
Снимаем ограничение версии, поднимаем `onnx-asr` до 0.12 и делаем
|
||||
поддерживаемыми три модели:
|
||||
|
||||
- `gigaam-multilingual-ctc` — смешанная речь с англоязычными терминами;
|
||||
- `gigaam-v3-e2e-ctc` и `gigaam-v3-e2e-rnnt` — пунктуация и нормализация текста.
|
||||
|
||||
Каталог моделей в бэкенде хранит, помимо имени, опубликованные для модели
|
||||
квантизации — сейчас такого места нет, потому что алиасы отображают имя в имя.
|
||||
Расширять существующую таблицу или заводить рядом отдельную структуру —
|
||||
решается при реализации. Passthrough сырых имён onnx-asr сохраняется: он не
|
||||
мешает и оставляет дверь для экспериментов.
|
||||
|
||||
Заодно поднимаем минимальную версию Python до 3.13 и ONNX Runtime до последней
|
||||
доступной (1.28 на момент написания). Python 3.10 в режиме security-only и
|
||||
снимается с поддержки в октябре 2026, а ORT 1.28 требует минимум 3.11 — так что
|
||||
подъём floor и обновление рантайма идут вместе. Следствие: зависимость `tomli` и
|
||||
условный импорт в `config.py` становятся мёртвым кодом и убираются — начиная с
|
||||
3.11 есть `tomllib`.
|
||||
|
||||
Floor берётся сразу 3.13, а не минимально достаточный 3.11: любой подъём версии
|
||||
всё равно требует прогона на всех трёх платформах, и дорого именно тестирование,
|
||||
а не строка в `pyproject.toml`. Один переезд вместо двух. Проект ставится там,
|
||||
где доступны uv и PyPI, поэтому uv сам поднимет нужный интерпретатор; закрытых
|
||||
контуров и оффлайн-зеркал среди пользователей нет.
|
||||
|
||||
Рабочая версия фиксируется файлом `.python-version` (сейчас его в репозитории
|
||||
нет). Floor разрешает любой интерпретатор от 3.13 и выше, а пин делает окружение
|
||||
одинаковым на разных машинах — иначе расхождение вылезает именно тогда, когда
|
||||
что-то отлаживаешь.
|
||||
|
||||
Колёса cp313 проверены для всех нативных зависимостей и всех трёх целевых
|
||||
платформ — Windows, Linux, macOS: `openvino-genai` 2026.0.0.0, `ctranslate2`
|
||||
4.7.1, `onnxruntime` 1.28.0. На macOS `openvino-genai` не ставится по
|
||||
существующему маркеру `sys_platform != 'darwin'`, так что там остаются
|
||||
faster-whisper на CPU и onnx-путь — это не меняется этой работой, но стоит
|
||||
помнить, раз появились пользователи на Mac.
|
||||
|
||||
GPU-пакеты ORT с их требованием CUDA 13 нас не касаются: CUDA идёт через
|
||||
CTranslate2, а onnx-путь ставится в CPU-варианте.
|
||||
|
||||
## Границы версий
|
||||
|
||||
Каждая прямая зависимость получает верхнюю границу по мажорной версии. Причина
|
||||
не в осторожности вообще, а в канале установки: README предлагает
|
||||
`uv tool install git+…`, а этот путь резолвит зависимости заново из метаданных
|
||||
пакета — `uv.lock` в колесо не попадает и не участвует. Диапазоны в
|
||||
`[project.dependencies]` — единственное, что ограничивает версии на машине
|
||||
пользователя. Установки в разные месяцы иначе дают разные наборы библиотек при
|
||||
одном и том же коде, и такие расхождения дороже разбирать, чем поднимать
|
||||
границы вручную.
|
||||
|
||||
Три случая, которые сами собой не решаются:
|
||||
|
||||
- **`onnxruntime` объявляется прямой зависимостью с границей**, хотя проект его
|
||||
не импортирует. `[tool.uv] constraint-dependencies` для этого не годится:
|
||||
по документации uv он действует только когда uv резолвит сам проект, и
|
||||
игнорируется, когда пакет ставят как зависимость.
|
||||
- **`nvidia-cublas-cu12` получает границу по мажорной версии.**
|
||||
`_cuda_bootstrap.py` преднагружает `libcublas.so.12` по точному soname
|
||||
([ADR-001](../adr/001-cuda-bootstrap.md)); мажорный апгрейд даёт другой soname
|
||||
и ломает bootstrap при неизменном коде.
|
||||
- **`openvino-genai` фиксируется на проверенной линии 2026.0**, а не просто «до
|
||||
следующего года». Иначе свежая установка подтянет 2026.3 и незаметно поменяет
|
||||
ту самую точку отсчёта, которую мы договорились не трогать. Границу снимает
|
||||
отдельная работа по обновлению OpenVINO — осознанно.
|
||||
|
||||
Конкретные номера берутся из `uv.lock` при реализации.
|
||||
|
||||
## Чего не делаем
|
||||
|
||||
- Не меняем модель по умолчанию и auto-detect: `gigaam-v3` остаётся дефолтом
|
||||
`--device onnx`. Решение о CPU-дефолте требует замеров на целевом Intel Core
|
||||
i5, которого сейчас нет в доступе.
|
||||
- Не обновляем OpenVINO, OpenVINO GenAI и CTranslate2. Whisper medium на
|
||||
OpenVINO — то, с чем сравниваются новые модели; менять его движок
|
||||
одновременно значит потерять точку отсчёта. Верхние границы версий им при
|
||||
этом проставляются — см. «Границы версий»; это фиксация текущего состояния, а
|
||||
не обновление.
|
||||
- Не добавляем алиасы `large-v3-turbo`, диаризацию, чанкование по паузам и
|
||||
словарь замен терминов — отдельные пункты [backlog](../backlog.md).
|
||||
|
||||
## Ловушки
|
||||
|
||||
- **`compute_type` разрешается по устройству, а не по модели.**
|
||||
`DEVICE_DEFAULTS["onnx"]` даёт `int8` независимо от модели, а `int8`
|
||||
опубликован не для всех. Для модели без запрошенной квантизации проект должен
|
||||
взять доступную и сказать об этом; явно заданное пользователем значение
|
||||
остаётся ошибкой, а не тихой подменой. Явным считается и значение из
|
||||
`.transcriber.toml`, не только флаг CLI — подстановка допустима лишь там, где
|
||||
квантизацию выбрал сам проект. Это единственное изменение в каскаде
|
||||
конфигурации.
|
||||
- **Язык у multilingual-модели.** Проект по умолчанию форсирует `ru`, а интерес
|
||||
— как раз смешанная речь. Надо посмотреть, принимает ли модель подсказку языка
|
||||
или определяет сама, и описать правило в README.
|
||||
- **E2E меняет форму текста.** Пунктуация и нормализация могут повлиять на
|
||||
группировку абзацев в `formatter.py` и на предупреждения `quality.py`,
|
||||
написанные под поведение Whisper.
|
||||
- **VAD.** В 0.12 исправлены сегменты нулевой длины после VAD — проект
|
||||
оборачивает модель в `.with_vad()`, так что изменение касается нас напрямую.
|
||||
|
||||
## Как проверяем
|
||||
|
||||
Работа идёт в отдельной ветке (по конвенции репозитория — `feature/…`), в
|
||||
`master` вливается только после ручной проверки. Причина не в процессе ради
|
||||
процесса: переезд на 3.13 пересобирает окружение целиком, и откатывать это на
|
||||
основной ветке неприятно. Ветка позволяет держать рабочий `master` под рукой,
|
||||
пока новое окружение не подтвердилось.
|
||||
|
||||
Внутри ветки смена окружения и смена библиотеки проверяются раздельно: сначала
|
||||
Python и зависимости при неизменных моделях — поведение обязано остаться
|
||||
прежним, — и только потом `onnx-asr` 0.12 с новыми моделями. Иначе при первой же
|
||||
странности подозреваемых окажется десяток и разделить их будет нечем, CI тут не
|
||||
поможет.
|
||||
|
||||
Проект домашний, CI нет, репрезентативные записи приватны и на разных ноутбуках
|
||||
разные — автоматическая приёмка невозможна в принципе, уверенность держится на
|
||||
ручных прогонах. Что она покрывает и чего не покрывает:
|
||||
|
||||
- существующие тесты замоканы ([`AGENTS.md`](../../AGENTS.md)) и подтверждают
|
||||
CLI, конфиг и форматирование, но не работоспособность движка: поломку ORT или
|
||||
onnx-asr видно только на реальной записи;
|
||||
- ручные прогоны делаются на Windows и Linux, по всем трём путям — onnx,
|
||||
openvino, cuda;
|
||||
- **macOS не проверяется вовсе.** Колёса cp313 там опубликованы, но это
|
||||
наличие, а не работоспособность; при жалобе с Mac исходить из того, что путь
|
||||
не валидировался;
|
||||
- **скорость на Intel Core i5 не измерялась.** Замеры делаются на Ryzen 7
|
||||
8845H и записываются как наблюдение с указанием CPU — для решения о
|
||||
CPU-дефолте этого недостаточно;
|
||||
- **качество моделей друг относительно друга не измерялось.** Ручная проверка
|
||||
отвечает на вопрос «работает ли», а не «лучше ли»; сравнение — отдельная
|
||||
работа, см. [backlog](../backlog.md).
|
||||
|
||||
Пошаговый чеклист и практика прогона нужны только на время работ и живут в
|
||||
задаче трекера
|
||||
([#1](https://git.dementev.space/ddmitry/local-transcriber/issues/1)), а не в
|
||||
этом документе.
|
||||
|
||||
## Документация
|
||||
|
||||
README: три модели в таблицу ONNX-моделей. Рекомендация остаётся прежней —
|
||||
`gigaam-v3` для русского — пока нет данных, чтобы её менять; у новых моделей
|
||||
стоит оговорка, что скорость на слабых CPU не измерялась. Требование Python
|
||||
правится в [`docs/PRD.md`](../PRD.md) — раздел «Требования» и таблица
|
||||
технологий; в README версии Python не упоминаются.
|
||||
@@ -12,6 +12,7 @@ dependencies = [
|
||||
"nvidia-cublas-cu12>=12.4; sys_platform == 'linux' and platform_machine == 'x86_64'",
|
||||
"openvino-genai>=2025.0; sys_platform != 'darwin' and (platform_machine == 'x86_64' or platform_machine == 'AMD64')",
|
||||
"tomli>=2.0; python_version < '3.11'",
|
||||
"onnx-asr[cpu,hub]>=0.11.0,<0.12.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -25,6 +25,15 @@ def get_backend(device: str, *, compute_type_explicit: bool = True) -> Backend:
|
||||
ov_device=device, compute_type_explicit=compute_type_explicit
|
||||
)
|
||||
|
||||
if device == "onnx":
|
||||
try:
|
||||
from .onnx_asr import OnnxAsrBackend
|
||||
except ImportError:
|
||||
raise ValueError(
|
||||
"onnx-asr бэкенд недоступен. Установите: pip install onnx-asr[cpu,hub]"
|
||||
) from None
|
||||
return OnnxAsrBackend()
|
||||
|
||||
# cuda, cpu и всё остальное → faster-whisper
|
||||
from .faster_whisper import FasterWhisperBackend
|
||||
|
||||
|
||||
@@ -30,8 +30,12 @@ class Backend(Protocol):
|
||||
model_path: str,
|
||||
device: str,
|
||||
compute_type: str,
|
||||
cpu_threads: int = 0,
|
||||
) -> Any:
|
||||
"""Создаёт модель. Возвращает backend-специфичный объект."""
|
||||
"""Создаёт модель. Возвращает backend-специфичный объект.
|
||||
|
||||
cpu_threads: число потоков для CPU inference (0 = дефолт библиотеки).
|
||||
"""
|
||||
...
|
||||
|
||||
def transcribe(
|
||||
|
||||
@@ -84,10 +84,17 @@ class FasterWhisperBackend:
|
||||
model_path: str,
|
||||
device: str,
|
||||
compute_type: str,
|
||||
cpu_threads: int = 0,
|
||||
) -> Any:
|
||||
"""Создаёт WhisperModel."""
|
||||
"""Создаёт WhisperModel.
|
||||
|
||||
cpu_threads: число потоков для CPU inference (0 = дефолт библиотеки, обычно 4).
|
||||
"""
|
||||
try:
|
||||
return WhisperModel(model_path, device=device, compute_type=compute_type)
|
||||
return WhisperModel(
|
||||
model_path, device=device, compute_type=compute_type,
|
||||
cpu_threads=cpu_threads,
|
||||
)
|
||||
except ImportError as exc:
|
||||
if _is_missing_socksio_error(exc):
|
||||
raise RuntimeError(
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Бэкенд транскрипции на основе onnx-asr (GigaAM, Parakeet, FastConformer)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from local_transcriber.types import Segment, TranscribeResult
|
||||
|
||||
MODEL_ALIASES: dict[str, str] = {
|
||||
"gigaam-v3": "gigaam-v3-ctc",
|
||||
"parakeet-v3": "nemo-parakeet-tdt-0.6b-v3",
|
||||
}
|
||||
|
||||
SUPPORTED_ALIASES = ", ".join(MODEL_ALIASES)
|
||||
|
||||
# compute_type проекта → onnx-asr quantization (file suffix; None = unquantized).
|
||||
_QUANTIZATION_MAP: dict[str, str | None] = {
|
||||
"int8": "int8",
|
||||
"fp16": "fp16",
|
||||
"float16": "fp16",
|
||||
"float32": None,
|
||||
"fp32": None,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_quantization(compute_type: str) -> str | None:
|
||||
"""Маппит compute_type проекта в значение onnx-asr ``quantization``.
|
||||
|
||||
onnx-asr использует ``quantization`` как суффикс имени файла модели:
|
||||
``int8``/``fp16`` подгружают квантизованные веса, ``None`` — unquantized
|
||||
(float32). Передача ``"float32"`` строкой пытается найти несуществующий
|
||||
файл с суффиксом ``_float32`` и приводит к ошибке загрузки.
|
||||
"""
|
||||
if compute_type not in _QUANTIZATION_MAP:
|
||||
supported = ", ".join(sorted(_QUANTIZATION_MAP))
|
||||
raise ValueError(
|
||||
f"Неподдерживаемый compute_type '{compute_type}' для onnx-asr. "
|
||||
f"Допустимо: {supported}."
|
||||
)
|
||||
return _QUANTIZATION_MAP[compute_type]
|
||||
|
||||
|
||||
class OnnxAsrBackend:
|
||||
"""Бэкенд транскрипции через onnx-asr (ONNX Runtime)."""
|
||||
|
||||
def __init__(self):
|
||||
self.actual_compute_type: str | None = None
|
||||
self._resolved_model_id: str | None = None
|
||||
self._vad: Any = None
|
||||
|
||||
def ensure_model_available(
|
||||
self,
|
||||
model_name: str,
|
||||
compute_type: str,
|
||||
on_status: Callable[[str], None] | None = None,
|
||||
) -> str:
|
||||
"""Resolves model alias and returns the onnx-asr model identifier.
|
||||
|
||||
onnx-asr downloads models automatically via load_model(),
|
||||
so this just validates the alias and returns the identifier string.
|
||||
"""
|
||||
self.actual_compute_type = compute_type
|
||||
self._resolved_model_id = self._resolve_model(model_name)
|
||||
return self._resolved_model_id
|
||||
|
||||
def create_model(
|
||||
self,
|
||||
model_path: str,
|
||||
device: str,
|
||||
compute_type: str,
|
||||
cpu_threads: int = 0,
|
||||
) -> Any:
|
||||
"""Creates onnx-asr model with VAD.
|
||||
|
||||
compute_type маппится в onnx-asr ``quantization`` — это суффикс файла
|
||||
модели; для unquantized (float32/fp32) нужно None, не строку.
|
||||
"""
|
||||
import onnx_asr
|
||||
|
||||
quantization = _normalize_quantization(compute_type)
|
||||
|
||||
model = onnx_asr.load_model(
|
||||
model=model_path,
|
||||
quantization=quantization,
|
||||
)
|
||||
vad = onnx_asr.load_vad("silero")
|
||||
self._vad = vad
|
||||
return model.with_vad(vad)
|
||||
|
||||
def transcribe(
|
||||
self,
|
||||
model: Any,
|
||||
file_path: Path,
|
||||
language: str | None,
|
||||
on_segment: Callable[[Segment], None] | None = None,
|
||||
on_status: Callable[[str], None] | None = None,
|
||||
) -> TranscribeResult:
|
||||
"""Transcribes audio file using onnx-asr model with VAD.
|
||||
|
||||
model: result of create_model() — a SegmentResultsAsrAdapter.
|
||||
file_path: path to audio/video file (any format supported by faster-whisper decode).
|
||||
language: language code (e.g. "ru", "en") — only meaningful for multilingual models.
|
||||
"""
|
||||
from faster_whisper import decode_audio
|
||||
|
||||
_notify(on_status, "Загружаю аудио...")
|
||||
audio_array = decode_audio(str(file_path), sampling_rate=16000)
|
||||
duration = len(audio_array) / 16000.0
|
||||
|
||||
_notify(on_status, "Транскрибирую (onnx-asr)...")
|
||||
segments: list[Segment] = []
|
||||
detected_language = language or "unknown"
|
||||
|
||||
for vad_seg in model.recognize(audio_array, sample_rate=16000, language=language):
|
||||
seg = Segment(
|
||||
start=max(0.0, vad_seg.start),
|
||||
end=max(0.0, vad_seg.end),
|
||||
text=vad_seg.text,
|
||||
)
|
||||
if on_segment is not None:
|
||||
on_segment(seg)
|
||||
segments.append(seg)
|
||||
_notify(
|
||||
on_status,
|
||||
f"Транскрибирую (onnx-asr)... [{len(segments)} сегм.]",
|
||||
)
|
||||
|
||||
return TranscribeResult(
|
||||
segments=segments,
|
||||
language=detected_language,
|
||||
language_probability=1.0 if language else 0.0,
|
||||
duration=duration,
|
||||
device_used="", # оркестратор проставит
|
||||
)
|
||||
|
||||
def _resolve_model(self, model_name: str) -> str:
|
||||
"""Resolve alias to onnx-asr model name. Raw names pass through."""
|
||||
if model_name in MODEL_ALIASES:
|
||||
return MODEL_ALIASES[model_name]
|
||||
if "/" in model_name or model_name.count("-") >= 2:
|
||||
# Looks like a raw onnx-asr name — allow passthrough
|
||||
return model_name
|
||||
raise ValueError(
|
||||
f"Неподдерживаемая модель '{model_name}'. "
|
||||
f"Доступные алиасы: {SUPPORTED_ALIASES}. "
|
||||
f"Либо укажите полное имя модели onnx-asr."
|
||||
)
|
||||
|
||||
|
||||
def _notify(on_status: Callable[[str], None] | None, message: str) -> None:
|
||||
if on_status is not None:
|
||||
on_status(message)
|
||||
@@ -20,6 +20,7 @@ MODEL_REPOS: dict[tuple[str, str], str] = {
|
||||
("base", "fp16"): "OpenVINO/whisper-base-fp16-ov",
|
||||
("small", "int8"): "OpenVINO/whisper-small-int8-ov",
|
||||
("medium", "int8"): "OpenVINO/whisper-medium-int8-ov",
|
||||
("medium", "fp16"): "OpenVINO/whisper-medium-fp16-ov",
|
||||
("large-v3", "int8"): "OpenVINO/whisper-large-v3-int8-ov",
|
||||
("large-v3", "fp16"): "OpenVINO/whisper-large-v3-fp16-ov",
|
||||
}
|
||||
@@ -105,8 +106,9 @@ class OpenVINOBackend:
|
||||
model_path: str,
|
||||
device: str,
|
||||
compute_type: str,
|
||||
cpu_threads: int = 0,
|
||||
) -> Any:
|
||||
"""Создаёт WhisperPipeline."""
|
||||
"""Создаёт WhisperPipeline. cpu_threads не используется (OpenVINO управляет сам)."""
|
||||
import openvino_genai as ov_genai
|
||||
|
||||
ov_dev = self._resolve_ov_device()
|
||||
|
||||
@@ -9,9 +9,23 @@ from rich.console import Console
|
||||
from rich.status import Status
|
||||
|
||||
from .config import apply_device_defaults, load_config, resolve_defaults
|
||||
from .formatter import format_transcript, write_transcript
|
||||
from .context_menu import install_menu as install_context_menu
|
||||
from .context_menu import uninstall_menu as uninstall_context_menu
|
||||
from .formatter import (
|
||||
format_duration,
|
||||
format_timestamp,
|
||||
format_transcript,
|
||||
write_transcript,
|
||||
)
|
||||
from .quality import (
|
||||
TAIL_GAP_WARN_S,
|
||||
RepetitionBlock,
|
||||
find_repetition_blocks,
|
||||
tail_gap,
|
||||
)
|
||||
from .transcriber import (
|
||||
Segment,
|
||||
TranscribeResult,
|
||||
_is_cuda_error,
|
||||
_transcribe_file,
|
||||
load_model,
|
||||
@@ -43,9 +57,62 @@ def _format_device_info(device_used: str) -> str:
|
||||
return "CPU"
|
||||
|
||||
|
||||
def _format_repetition_blocks(
|
||||
blocks: list[RepetitionBlock],
|
||||
use_hours: bool,
|
||||
) -> str:
|
||||
"""Формирует краткое описание блоков повторов для консоли."""
|
||||
rendered = [
|
||||
f"[{format_timestamp(block.start, use_hours=use_hours)} - "
|
||||
f"{format_timestamp(block.end, use_hours=use_hours)}] ({block.count}×)"
|
||||
for block in blocks[:3]
|
||||
]
|
||||
summary = "; ".join(rendered)
|
||||
remaining = len(blocks) - 3
|
||||
if remaining > 0:
|
||||
summary = f"{summary} (+ ещё {remaining})"
|
||||
return summary
|
||||
|
||||
|
||||
def _print_quality_warnings(result: TranscribeResult, file_name: str | None = None) -> None:
|
||||
"""Печатает предупреждения о возможной потере содержания."""
|
||||
is_batch = file_name is not None
|
||||
use_hours = result.duration > 3600
|
||||
|
||||
gap = tail_gap(result)
|
||||
if gap > TAIL_GAP_WARN_S:
|
||||
covered = format_duration(result.segments[-1].end)
|
||||
total = format_duration(result.duration)
|
||||
message = (
|
||||
f"транскрипт покрывает {covered} из {total} — "
|
||||
"возможна потеря хвоста записи"
|
||||
)
|
||||
if is_batch:
|
||||
console.print(f" {file_name}: {message}", style="yellow")
|
||||
else:
|
||||
console.print(
|
||||
f"Внимание: {message}. Попробуйте другой --device.",
|
||||
style="yellow",
|
||||
)
|
||||
|
||||
blocks = find_repetition_blocks(result.segments)
|
||||
if blocks:
|
||||
message = (
|
||||
f"блоки повторов: {_format_repetition_blocks(blocks, use_hours)} "
|
||||
"— возможны галлюцинации модели"
|
||||
)
|
||||
if is_batch:
|
||||
console.print(f" {file_name}: {message}", style="yellow")
|
||||
else:
|
||||
console.print(
|
||||
f"Внимание: {message}. Попробуйте другой --device.",
|
||||
style="yellow",
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
def main(
|
||||
files: list[Path] = typer.Argument(..., help="Пути к аудио/видеофайлам"),
|
||||
files: list[Path] | None = typer.Argument(None, help="Пути к аудио/видеофайлам"),
|
||||
model: str | None = typer.Option(
|
||||
None, "--model", "-m", show_default=False, help="Модель Whisper [по умолч.: medium]"
|
||||
),
|
||||
@@ -55,19 +122,60 @@ def main(
|
||||
output: Path | None = typer.Option(None, "--output", "-o", help="Путь к выходному файлу"),
|
||||
device: str | None = typer.Option(
|
||||
None, "--device", "-d", show_default=False,
|
||||
help="Устройство (auto|cpu|cuda|openvino|openvino-gpu|openvino-cpu) [по умолч.: auto]"
|
||||
help="Устройство (auto|cpu|cuda|openvino|openvino-gpu|openvino-cpu|onnx) [по умолч.: auto]"
|
||||
),
|
||||
compute_type: str | None = typer.Option(
|
||||
None, "--compute-type", show_default=False,
|
||||
help="Тип вычислений [по умолч.: float16 (CUDA) / int8 (OpenVINO GPU/CPU) / float32 (CPU)]"
|
||||
),
|
||||
threads: int = typer.Option(
|
||||
0, "--threads", "-t", show_default=False, min=0,
|
||||
help="Потоки CPU (0 = дефолт библиотеки; рекомендуется = число физ. ядер)"
|
||||
),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Подробный вывод"),
|
||||
force: bool = typer.Option(False, "--force", "-f", help="Перезаписать существующие транскрипты"),
|
||||
install_menu: bool = typer.Option(False, "--install-menu", help="Установить пункт Transcribe в SendTo"),
|
||||
uninstall_menu: bool = typer.Option(False, "--uninstall-menu", help="Удалить пункт Transcribe из SendTo"),
|
||||
) -> None:
|
||||
"""Транскрибирует аудио/видеофайлы в markdown с таймкодами.
|
||||
|
||||
Каскад приоритетов параметров: CLI-флаги > .transcriber.toml > device-aware дефолты.
|
||||
"""
|
||||
files = [] if files is None else files
|
||||
|
||||
if install_menu or uninstall_menu:
|
||||
if install_menu and uninstall_menu:
|
||||
console.print("--install-menu и --uninstall-menu несовместимы.", style="red bold")
|
||||
raise SystemExit(2)
|
||||
if files:
|
||||
console.print("Флаги меню нельзя использовать вместе с файлами.", style="red bold")
|
||||
raise SystemExit(2)
|
||||
if sys.platform != "win32":
|
||||
console.print("Пункт меню SendTo доступен только на Windows.", style="red bold")
|
||||
raise SystemExit(1)
|
||||
|
||||
try:
|
||||
if install_menu:
|
||||
cmd_path = install_context_menu()
|
||||
console.print(f"Пункт меню установлен: \"{cmd_path}\"", style="green")
|
||||
else:
|
||||
cmd_path = uninstall_context_menu()
|
||||
if cmd_path is None:
|
||||
console.print("Пункт меню не был установлен.", style="yellow")
|
||||
else:
|
||||
console.print(f"Пункт меню удалён: \"{cmd_path}\"", style="green")
|
||||
except RuntimeError as exc:
|
||||
console.print(f"Ошибка: {exc}", style="red bold")
|
||||
raise SystemExit(1)
|
||||
return
|
||||
|
||||
if not files:
|
||||
console.print(
|
||||
"Укажите хотя бы один файл или используйте --install-menu/--uninstall-menu.",
|
||||
style="red bold",
|
||||
)
|
||||
raise SystemExit(2)
|
||||
|
||||
try:
|
||||
config = load_config()
|
||||
cli_values = {"model": model, "language": language, "device": device, "compute_type": compute_type}
|
||||
@@ -89,9 +197,9 @@ def main(
|
||||
raise SystemExit(1)
|
||||
|
||||
if is_batch:
|
||||
_run_batch(expanded, defaults, verbose, force, ct_explicit)
|
||||
_run_batch(expanded, defaults, verbose, force, ct_explicit, cpu_threads=threads)
|
||||
else:
|
||||
_run_single(expanded[0], defaults, output, verbose, ct_explicit)
|
||||
_run_single(expanded[0], defaults, output, verbose, ct_explicit, cpu_threads=threads)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\nПрервано пользователем.", style="yellow")
|
||||
raise SystemExit(130)
|
||||
@@ -129,6 +237,7 @@ def _run_single(
|
||||
output: Path | None,
|
||||
verbose: bool,
|
||||
compute_type_explicit: bool = False,
|
||||
cpu_threads: int = 0,
|
||||
) -> None:
|
||||
"""Пайплайн одного файла: валидация → модель → транскрипция → запись."""
|
||||
start = time.monotonic()
|
||||
@@ -148,6 +257,7 @@ def _run_single(
|
||||
defaults["model"], resolved_device, defaults["compute_type"],
|
||||
on_status=lambda msg: console.print(msg), strict_device=strict,
|
||||
compute_type_explicit=compute_type_explicit,
|
||||
cpu_threads=cpu_threads,
|
||||
)
|
||||
actual_ct = getattr(backend, "actual_compute_type", defaults["compute_type"]) or defaults["compute_type"]
|
||||
console.print(
|
||||
@@ -174,6 +284,7 @@ def _run_single(
|
||||
on_segment=on_segment if verbose else None,
|
||||
on_status=status.update,
|
||||
strict_device=strict,
|
||||
cpu_threads=cpu_threads,
|
||||
)
|
||||
|
||||
result = tfr.result
|
||||
@@ -211,6 +322,7 @@ def _run_single(
|
||||
elapsed = time.monotonic() - start
|
||||
console.print(f"Транскрипт сохранён: \"{output_path}\"", style="green")
|
||||
console.print(f" Сегментов: {len(result.segments)} Время: {elapsed:.1f}с")
|
||||
_print_quality_warnings(result)
|
||||
|
||||
|
||||
def _run_batch(
|
||||
@@ -219,6 +331,7 @@ def _run_batch(
|
||||
verbose: bool,
|
||||
force: bool,
|
||||
compute_type_explicit: bool = False,
|
||||
cpu_threads: int = 0,
|
||||
) -> None:
|
||||
"""Трёхфазный батч-пайплайн: prescan → загрузка модели → транскрипция."""
|
||||
# Phase 1: Prescan — fail-fast + skip до загрузки модели (экономим ~2-5 сек)
|
||||
@@ -256,6 +369,7 @@ def _run_batch(
|
||||
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":
|
||||
@@ -306,6 +420,7 @@ def _run_batch(
|
||||
on_segment=on_segment if verbose else None,
|
||||
on_status=status.update if not verbose else lambda msg: console.print(msg),
|
||||
strict_device=strict,
|
||||
cpu_threads=cpu_threads,
|
||||
)
|
||||
|
||||
if tfr.actual_device != actual_device:
|
||||
@@ -343,6 +458,7 @@ def _run_batch(
|
||||
style="green",
|
||||
)
|
||||
processed += 1
|
||||
_print_quality_warnings(result, file.name)
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except Exception as exc:
|
||||
|
||||
@@ -22,11 +22,12 @@ DEVICE_DEFAULTS: dict[str, dict[str, str]] = {
|
||||
"openvino": {"model": "medium", "compute_type": "int8"},
|
||||
"openvino-gpu": {"model": "medium", "compute_type": "int8"},
|
||||
"openvino-cpu": {"model": "medium", "compute_type": "int8"},
|
||||
"onnx": {"model": "gigaam-v3", "compute_type": "int8"},
|
||||
}
|
||||
|
||||
# Одно место правды для допустимых ключей конфига
|
||||
_VALID_KEYS = set(HARDCODED_DEFAULTS)
|
||||
_VALID_DEVICES = {"auto", "cpu", "cuda", "openvino", "openvino-gpu", "openvino-cpu"}
|
||||
_VALID_DEVICES = {"auto", "cpu", "cuda", "openvino", "openvino-gpu", "openvino-cpu", "onnx"}
|
||||
|
||||
|
||||
def find_config_file() -> Path | None:
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Установка пункта Transcribe в меню SendTo проводника Windows."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CMD_NAME = "Transcribe.cmd"
|
||||
CMD_ENCODING = "oem"
|
||||
|
||||
|
||||
def get_sendto_dir() -> Path:
|
||||
"""Возвращает путь к пользовательской папке SendTo."""
|
||||
appdata = os.environ.get("APPDATA")
|
||||
if appdata is None:
|
||||
raise RuntimeError("Переменная окружения APPDATA не задана.")
|
||||
return Path(appdata) / "Microsoft" / "Windows" / "SendTo"
|
||||
|
||||
|
||||
def get_transcribe_exe() -> Path:
|
||||
"""Возвращает путь к transcribe.exe рядом с текущим интерпретатором."""
|
||||
transcribe_exe = Path(sys.executable).parent / "transcribe.exe"
|
||||
if not transcribe_exe.exists():
|
||||
raise RuntimeError(
|
||||
f"Не найден transcribe.exe рядом с Python: {transcribe_exe}. "
|
||||
"Выполните uv sync и повторите установку пункта меню."
|
||||
)
|
||||
return transcribe_exe
|
||||
|
||||
|
||||
def install_menu() -> Path:
|
||||
"""Создаёт или обновляет Transcribe.cmd в папке SendTo."""
|
||||
sendto_dir = get_sendto_dir()
|
||||
transcribe_exe = get_transcribe_exe()
|
||||
cmd_path = sendto_dir / CMD_NAME
|
||||
content = f'@echo off\r\n"{transcribe_exe}" %*\r\npause\r\n'
|
||||
|
||||
try:
|
||||
encoded_content = content.encode(CMD_ENCODING)
|
||||
except UnicodeEncodeError as exc:
|
||||
raise RuntimeError(
|
||||
"Путь к transcribe.exe содержит символы, которые нельзя записать "
|
||||
"в OEM-кодировке cmd.exe. Установите проект в путь без таких символов "
|
||||
"и повторите --install-menu."
|
||||
) from exc
|
||||
|
||||
sendto_dir.mkdir(parents=True, exist_ok=True)
|
||||
cmd_path.write_bytes(encoded_content)
|
||||
return cmd_path
|
||||
|
||||
|
||||
def uninstall_menu() -> Path | None:
|
||||
"""Удаляет Transcribe.cmd из папки SendTo, если он существует."""
|
||||
cmd_path = get_sendto_dir() / CMD_NAME
|
||||
if not cmd_path.exists():
|
||||
return None
|
||||
cmd_path.unlink()
|
||||
return cmd_path
|
||||
@@ -4,6 +4,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from .quality import TAIL_GAP_WARN_S, find_repetition_blocks, tail_gap
|
||||
from .types import Segment, TranscribeResult
|
||||
|
||||
_PAUSE_THRESHOLD_S = 2.0 # пауза между сегментами для разбиения на абзацы
|
||||
@@ -63,7 +64,7 @@ def format_timestamp(seconds: float, use_hours: bool = False) -> str:
|
||||
return f"{minutes:02d}:{secs:02d}.{centiseconds:02d}"
|
||||
|
||||
|
||||
def _format_duration(seconds: float) -> str:
|
||||
def format_duration(seconds: float) -> str:
|
||||
"""Человекочитаемая длительность для метаданных в шапке транскрипта."""
|
||||
total = int(seconds)
|
||||
h = total // 3600
|
||||
@@ -92,7 +93,20 @@ def format_transcript(
|
||||
lines.append(f"- **Дата транскрипции**: {date.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
lines.append(f"- **Модель**: {model_name}")
|
||||
lines.append(f"- **Язык**: {result.language} ({language_mode})")
|
||||
lines.append(f"- **Длительность**: {_format_duration(result.duration)}")
|
||||
lines.append(f"- **Длительность**: {format_duration(result.duration)}")
|
||||
if tail_gap(result) > TAIL_GAP_WARN_S:
|
||||
last_end = result.segments[-1].end
|
||||
lines.append(
|
||||
f"- **Внимание**: транскрипт покрывает {format_duration(last_end)} "
|
||||
f"из {format_duration(result.duration)} — возможна потеря хвоста записи"
|
||||
)
|
||||
for block in find_repetition_blocks(result.segments):
|
||||
start = format_timestamp(block.start, use_hours=use_hours)
|
||||
end = format_timestamp(block.end, use_hours=use_hours)
|
||||
lines.append(
|
||||
f"- **Внимание**: повторы в [{start} - {end}] ({block.count}×) "
|
||||
"— возможны галлюцинации модели"
|
||||
)
|
||||
lines.append(f"- **Устройство**: {device_info}")
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Эвристики качества транскрипта."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .types import Segment, TranscribeResult
|
||||
|
||||
TAIL_GAP_WARN_S = 120.0
|
||||
REPETITION_MIN_RUN = 4
|
||||
REPETITION_MIN_RUN_SHORT = 10
|
||||
REPETITION_MIN_LEN = 6
|
||||
|
||||
_PUNCTUATION_TO_REMOVE = ".,!?…:;—–-\"'«»()[]<>"
|
||||
_REMOVE_PUNCTUATION = str.maketrans("", "", _PUNCTUATION_TO_REMOVE)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RepetitionBlock:
|
||||
start: float
|
||||
end: float
|
||||
count: int
|
||||
text: str
|
||||
|
||||
|
||||
def tail_gap(result: TranscribeResult) -> float:
|
||||
"""Возвращает непокрытый хвост записи в секундах."""
|
||||
if not result.segments:
|
||||
return 0.0
|
||||
return max(0.0, result.duration - result.segments[-1].end)
|
||||
|
||||
|
||||
def _normalize(text: str) -> str:
|
||||
"""Нормализует текст сегмента для поиска межсегментных повторов."""
|
||||
text = text.casefold()
|
||||
text = text.translate(_REMOVE_PUNCTUATION)
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def find_repetition_blocks(segments: list[Segment]) -> list[RepetitionBlock]:
|
||||
"""Находит серии подряд идущих одинаковых сегментов."""
|
||||
blocks: list[RepetitionBlock] = []
|
||||
run_start = 0
|
||||
run_norm = ""
|
||||
|
||||
def append_run(run_end: int) -> None:
|
||||
count = run_end - run_start
|
||||
if not run_norm:
|
||||
return
|
||||
min_run = (
|
||||
REPETITION_MIN_RUN
|
||||
if len(run_norm) >= REPETITION_MIN_LEN
|
||||
else REPETITION_MIN_RUN_SHORT
|
||||
)
|
||||
if count >= min_run:
|
||||
blocks.append(
|
||||
RepetitionBlock(
|
||||
start=segments[run_start].start,
|
||||
end=segments[run_end - 1].end,
|
||||
count=count,
|
||||
text=segments[run_start].text,
|
||||
)
|
||||
)
|
||||
|
||||
for index, segment in enumerate(segments):
|
||||
norm = _normalize(segment.text)
|
||||
if index == 0:
|
||||
run_start = 0
|
||||
run_norm = norm
|
||||
continue
|
||||
if norm == run_norm:
|
||||
continue
|
||||
append_run(index)
|
||||
run_start = index
|
||||
run_norm = norm
|
||||
|
||||
if segments:
|
||||
append_run(len(segments))
|
||||
|
||||
return blocks
|
||||
@@ -22,11 +22,13 @@ def load_model(
|
||||
on_status: Callable[[str], None] | None = None,
|
||||
strict_device: bool = False,
|
||||
compute_type_explicit: bool = False,
|
||||
cpu_threads: int = 0,
|
||||
) -> tuple[Any, str, Any, str]:
|
||||
"""Загружает модель: ensure + create с fallback.
|
||||
|
||||
Возвращает (model, actual_device, backend, model_path).
|
||||
compute_type_explicit: True если пользователь явно указал --compute-type.
|
||||
cpu_threads: число потоков для CPU inference (0 = дефолт библиотеки).
|
||||
"""
|
||||
backend = get_backend(device, compute_type_explicit=compute_type_explicit)
|
||||
actual_device = device
|
||||
@@ -35,7 +37,7 @@ def load_model(
|
||||
|
||||
try:
|
||||
_notify_status(on_status, f"Инициализирую модель на {device}...")
|
||||
model = backend.create_model(model_path, device, compute_type)
|
||||
model = backend.create_model(model_path, device, compute_type, cpu_threads=cpu_threads)
|
||||
# Резолвим actual_device по реальному OpenVINO device
|
||||
ov_dev = getattr(backend, "actual_ov_device", None)
|
||||
if ov_dev == "GPU" and actual_device != "openvino-gpu":
|
||||
@@ -55,7 +57,7 @@ def load_model(
|
||||
backend = get_backend("cpu")
|
||||
model_path = backend.ensure_model_available(model_name, compute_type, on_status)
|
||||
_notify_status(on_status, "Инициализирую модель на cpu...")
|
||||
model = backend.create_model(model_path, "cpu", compute_type)
|
||||
model = backend.create_model(model_path, "cpu", compute_type, cpu_threads=cpu_threads)
|
||||
else:
|
||||
raise
|
||||
|
||||
@@ -74,6 +76,7 @@ def _transcribe_file(
|
||||
on_segment: Callable[[Segment], None] | None = None,
|
||||
on_status: Callable[[str], None] | None = None,
|
||||
strict_device: bool = False,
|
||||
cpu_threads: int = 0,
|
||||
) -> TranscribeFileResult:
|
||||
"""Транскрибирует один файл. При mid-stream fallback перезагружает модель."""
|
||||
lang_arg = language if language and language != "auto" else None
|
||||
@@ -95,7 +98,7 @@ def _transcribe_file(
|
||||
backend = get_backend("cpu")
|
||||
model_path = backend.ensure_model_available(model_name, compute_type, on_status)
|
||||
_notify_status(on_status, "Инициализирую модель на cpu...")
|
||||
model = backend.create_model(model_path, "cpu", compute_type)
|
||||
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
|
||||
@@ -120,16 +123,19 @@ def transcribe(
|
||||
on_segment: Callable[[Segment], None] | None = None,
|
||||
on_status: Callable[[str], None] | None = None,
|
||||
strict_device: bool = False,
|
||||
cpu_threads: int = 0,
|
||||
) -> TranscribeResult:
|
||||
"""High-level API: загрузка модели + транскрипция за один вызов."""
|
||||
model, actual_device, backend, model_path = load_model(
|
||||
model_name, device, compute_type, on_status, strict_device,
|
||||
compute_type_explicit=True, # Python API — caller explicitly chose compute_type
|
||||
cpu_threads=cpu_threads,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ def test_resolve_repo_explicit_unsupported_pair_raises():
|
||||
"""Явный --compute-type с несуществующей парой → ошибка."""
|
||||
backend = OpenVINOBackend(compute_type_explicit=True)
|
||||
with pytest.raises(ValueError, match="недоступна с compute_type='fp16'"):
|
||||
backend._resolve_repo("medium", "fp16")
|
||||
backend._resolve_repo("small", "fp16")
|
||||
|
||||
|
||||
def test_resolve_repo_explicit_unknown_model_raises():
|
||||
|
||||
@@ -2,6 +2,7 @@ from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from local_transcriber.cli import _format_device_info, app
|
||||
@@ -862,3 +863,308 @@ def test_cli_openvino_alias_resolves_to_gpu(tmp_path):
|
||||
assert out.exit_code == 0
|
||||
# detect_device("openvino") resolved to "openvino-gpu", load_model receives it
|
||||
assert mock_load_model.call_args[0][1] == "openvino-gpu"
|
||||
|
||||
|
||||
# === --threads ===
|
||||
|
||||
|
||||
def test_cli_threads_passed_to_load_model(tmp_path):
|
||||
"""--threads передаётся в load_model как cpu_threads."""
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
result = _make_result()
|
||||
model = _make_model()
|
||||
backend = _make_backend()
|
||||
tfr = _make_tfr(result=result, model=model, backend=backend)
|
||||
mock_load_model = MagicMock(return_value=(model, "cpu", backend, "/models/medium"))
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.validate_input_file", return_value=audio),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||
patch("local_transcriber.cli.load_model", mock_load_model),
|
||||
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
out = runner.invoke(app, [str(audio), "--threads", "8"])
|
||||
|
||||
assert out.exit_code == 0
|
||||
assert mock_load_model.call_args.kwargs["cpu_threads"] == 8
|
||||
|
||||
|
||||
def test_cli_threads_default_zero(tmp_path):
|
||||
"""Без --threads load_model получает cpu_threads=0."""
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
result = _make_result()
|
||||
model = _make_model()
|
||||
backend = _make_backend()
|
||||
tfr = _make_tfr(result=result, model=model, backend=backend)
|
||||
mock_load_model = MagicMock(return_value=(model, "cpu", backend, "/models/medium"))
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.validate_input_file", return_value=audio),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||
patch("local_transcriber.cli.load_model", mock_load_model),
|
||||
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
out = runner.invoke(app, [str(audio)])
|
||||
|
||||
assert out.exit_code == 0
|
||||
assert mock_load_model.call_args.kwargs["cpu_threads"] == 0
|
||||
|
||||
|
||||
def test_cli_threads_negative_rejected(tmp_path):
|
||||
"""--threads с отрицательным значением отклоняется typer (min=0)."""
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
out = runner.invoke(app, [str(audio), "--threads", "-1"])
|
||||
assert out.exit_code != 0
|
||||
|
||||
|
||||
# === SendTo context menu flags ===
|
||||
|
||||
|
||||
def test_cli_install_menu_success(tmp_path):
|
||||
cmd_path = tmp_path / "Transcribe.cmd"
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.install_context_menu", return_value=cmd_path) as mock_install,
|
||||
patch("local_transcriber.cli.load_config") as mock_load_config,
|
||||
patch("local_transcriber.cli.sys") as mock_sys,
|
||||
):
|
||||
mock_sys.platform = "win32"
|
||||
out = runner.invoke(app, ["--install-menu"])
|
||||
|
||||
assert out.exit_code == 0
|
||||
assert "Пункт меню установлен" in out.output
|
||||
assert cmd_path.name in out.output
|
||||
mock_install.assert_called_once_with()
|
||||
mock_load_config.assert_not_called()
|
||||
|
||||
|
||||
def test_cli_uninstall_menu_success(tmp_path):
|
||||
cmd_path = tmp_path / "Transcribe.cmd"
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.uninstall_context_menu", return_value=cmd_path) as mock_uninstall,
|
||||
patch("local_transcriber.cli.load_config") as mock_load_config,
|
||||
patch("local_transcriber.cli.sys") as mock_sys,
|
||||
):
|
||||
mock_sys.platform = "win32"
|
||||
out = runner.invoke(app, ["--uninstall-menu"])
|
||||
|
||||
assert out.exit_code == 0
|
||||
assert "Пункт меню удалён" in out.output
|
||||
assert cmd_path.name in out.output
|
||||
mock_uninstall.assert_called_once_with()
|
||||
mock_load_config.assert_not_called()
|
||||
|
||||
|
||||
def test_cli_uninstall_menu_missing_is_success():
|
||||
with (
|
||||
patch("local_transcriber.cli.uninstall_context_menu", return_value=None),
|
||||
patch("local_transcriber.cli.sys") as mock_sys,
|
||||
):
|
||||
mock_sys.platform = "win32"
|
||||
out = runner.invoke(app, ["--uninstall-menu"])
|
||||
|
||||
assert out.exit_code == 0
|
||||
assert "не был установлен" in out.output
|
||||
|
||||
|
||||
def test_cli_menu_flags_are_mutually_exclusive():
|
||||
out = runner.invoke(app, ["--install-menu", "--uninstall-menu"])
|
||||
|
||||
assert out.exit_code == 2
|
||||
assert "несовместимы" in out.output
|
||||
|
||||
|
||||
def test_cli_menu_flag_with_file_is_rejected(tmp_path):
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
|
||||
out = runner.invoke(app, [str(audio), "--install-menu"])
|
||||
|
||||
assert out.exit_code == 2
|
||||
assert "нельзя использовать вместе с файлами" in out.output
|
||||
|
||||
|
||||
def test_cli_no_files_and_no_menu_flags_is_rejected():
|
||||
out = runner.invoke(app, [])
|
||||
|
||||
assert out.exit_code == 2
|
||||
assert "Укажите хотя бы один файл" in out.output
|
||||
|
||||
|
||||
def test_cli_menu_flags_available_only_on_windows():
|
||||
with patch("local_transcriber.cli.sys") as mock_sys:
|
||||
mock_sys.platform = "linux"
|
||||
out = runner.invoke(app, ["--install-menu"])
|
||||
|
||||
assert out.exit_code == 1
|
||||
assert "только на Windows" in out.output
|
||||
|
||||
|
||||
def test_cli_menu_runtime_error_has_no_verbose_hint():
|
||||
with (
|
||||
patch("local_transcriber.cli.install_context_menu", side_effect=RuntimeError("нет APPDATA")),
|
||||
patch("local_transcriber.cli.sys") as mock_sys,
|
||||
):
|
||||
mock_sys.platform = "win32"
|
||||
out = runner.invoke(app, ["--install-menu"])
|
||||
|
||||
assert out.exit_code == 1
|
||||
assert "нет APPDATA" in out.output
|
||||
assert "--verbose" not in out.output
|
||||
|
||||
|
||||
def test_cli_tail_gap_quality_warning_single(tmp_path):
|
||||
audio = tmp_path / "tail.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
result = _make_result(
|
||||
segments=[Segment(start=0.0, end=60.0, text="Фраза")],
|
||||
duration=600.0,
|
||||
)
|
||||
|
||||
patches = _single_patches(result=result, tmp_file=audio)
|
||||
with (
|
||||
patches[0],
|
||||
patches[1],
|
||||
patches[2],
|
||||
patches[3],
|
||||
patches[4],
|
||||
patches[5],
|
||||
patch("local_transcriber.cli.console", Console(stderr=True, width=1000)),
|
||||
):
|
||||
out = runner.invoke(app, [str(audio)])
|
||||
|
||||
assert out.exit_code == 0
|
||||
assert (
|
||||
"Внимание: транскрипт покрывает 01:00 из 10:00 — "
|
||||
"возможна потеря хвоста записи. Попробуйте другой --device."
|
||||
) in out.output
|
||||
|
||||
|
||||
def test_cli_repetition_quality_warning_single(tmp_path):
|
||||
audio = tmp_path / "repeat.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
result = _make_result(
|
||||
segments=[
|
||||
Segment(start=10.0, end=11.0, text="Повторяемая фраза"),
|
||||
Segment(start=11.0, end=12.0, text="повторяемая фраза"),
|
||||
Segment(start=12.0, end=13.0, text="повторяемая фраза"),
|
||||
Segment(start=13.0, end=14.0, text="повторяемая фраза"),
|
||||
],
|
||||
duration=60.0,
|
||||
)
|
||||
|
||||
patches = _single_patches(result=result, tmp_file=audio)
|
||||
with (
|
||||
patches[0],
|
||||
patches[1],
|
||||
patches[2],
|
||||
patches[3],
|
||||
patches[4],
|
||||
patches[5],
|
||||
patch("local_transcriber.cli.console", Console(stderr=True, width=1000)),
|
||||
):
|
||||
out = runner.invoke(app, [str(audio)])
|
||||
|
||||
assert out.exit_code == 0
|
||||
assert (
|
||||
"Внимание: блоки повторов: [00:10.00 - 00:14.00] (4×) — "
|
||||
"возможны галлюцинации модели. Попробуйте другой --device."
|
||||
) in out.output
|
||||
|
||||
|
||||
def test_cli_quality_warning_batch_includes_file_name(tmp_path):
|
||||
a = tmp_path / "a.mp3"
|
||||
b = tmp_path / "b.mp3"
|
||||
a.write_bytes(b"fake")
|
||||
b.write_bytes(b"fake")
|
||||
|
||||
result_warn = _make_result(
|
||||
segments=[Segment(start=0.0, end=60.0, text="Фраза")],
|
||||
duration=600.0,
|
||||
)
|
||||
result_ok = _make_result()
|
||||
model = _make_model()
|
||||
backend = _make_backend()
|
||||
tfr_warn = _make_tfr(result=result_warn, model=model, backend=backend)
|
||||
tfr_ok = _make_tfr(result=result_ok, model=model, backend=backend)
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||
patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")),
|
||||
patch("local_transcriber.cli._transcribe_file", side_effect=[tfr_warn, tfr_ok]),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
patch("local_transcriber.cli.console", Console(stderr=True, width=1000)),
|
||||
):
|
||||
out = runner.invoke(app, [str(a), str(b)])
|
||||
|
||||
assert out.exit_code == 0
|
||||
assert (
|
||||
" a.mp3: транскрипт покрывает 01:00 из 10:00 — "
|
||||
"возможна потеря хвоста записи"
|
||||
) in out.output
|
||||
|
||||
|
||||
def test_cli_repetition_quality_warning_truncates_after_three_blocks(tmp_path):
|
||||
audio = tmp_path / "repeat-many.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
|
||||
def run(start, count, text):
|
||||
return [
|
||||
Segment(start=start + index, end=start + index + 1.0, text=text)
|
||||
for index in range(count)
|
||||
]
|
||||
|
||||
result = _make_result(
|
||||
segments=[
|
||||
*run(10.0, 6, "Первый повтор"),
|
||||
Segment(start=18.0, end=19.0, text="Разрыв один"),
|
||||
*run(20.0, 5, "Второй повтор"),
|
||||
Segment(start=28.0, end=29.0, text="Разрыв два"),
|
||||
*run(30.0, 4, "Третий повтор"),
|
||||
Segment(start=38.0, end=39.0, text="Разрыв три"),
|
||||
*run(40.0, 4, "Четвёртый повтор"),
|
||||
],
|
||||
duration=90.0,
|
||||
)
|
||||
|
||||
patches = _single_patches(result=result, tmp_file=audio)
|
||||
with (
|
||||
patches[0],
|
||||
patches[1],
|
||||
patches[2],
|
||||
patches[3],
|
||||
patches[4],
|
||||
patches[5],
|
||||
patch("local_transcriber.cli.console", Console(stderr=True, width=1000)),
|
||||
):
|
||||
out = runner.invoke(app, [str(audio)])
|
||||
|
||||
assert out.exit_code == 0
|
||||
assert (
|
||||
"Внимание: блоки повторов: [00:10.00 - 00:16.00] (6×); "
|
||||
"[00:20.00 - 00:25.00] (5×); [00:30.00 - 00:34.00] (4×) "
|
||||
"(+ ещё 1) — возможны галлюцинации модели. Попробуйте другой --device."
|
||||
) in out.output
|
||||
|
||||
|
||||
def test_cli_default_result_has_no_quality_warnings(tmp_path):
|
||||
audio = tmp_path / "normal.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
|
||||
patches = _single_patches(tmp_file=audio)
|
||||
with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5]:
|
||||
out = runner.invoke(app, [str(audio)])
|
||||
|
||||
assert out.exit_code == 0
|
||||
assert "потеря хвоста" not in out.output
|
||||
assert "галлюцинации" not in out.output
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from local_transcriber import context_menu
|
||||
|
||||
|
||||
def _prepare_exe(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
scripts_dir = tmp_path / "venv" / "Scripts"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
python_exe = scripts_dir / "python.exe"
|
||||
transcribe_exe = scripts_dir / "transcribe.exe"
|
||||
python_exe.write_bytes(b"")
|
||||
transcribe_exe.write_bytes(b"")
|
||||
monkeypatch.setattr(context_menu.sys, "executable", str(python_exe))
|
||||
return transcribe_exe
|
||||
|
||||
|
||||
def test_install_menu_creates_expected_cmd(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming"))
|
||||
monkeypatch.setattr(context_menu, "CMD_ENCODING", "utf-8")
|
||||
transcribe_exe = _prepare_exe(tmp_path, monkeypatch)
|
||||
|
||||
cmd_path = context_menu.install_menu()
|
||||
|
||||
assert cmd_path.name == "Transcribe.cmd"
|
||||
assert cmd_path.exists()
|
||||
assert cmd_path.read_bytes() == (
|
||||
f'@echo off\r\n"{transcribe_exe}" %*\r\npause\r\n'.encode("utf-8")
|
||||
)
|
||||
assert b"chcp" not in cmd_path.read_bytes().lower()
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="Кодировка oem доступна только на Windows")
|
||||
def test_install_menu_writes_real_oem_encoding_on_windows(tmp_path, monkeypatch):
|
||||
appdata = tmp_path / "AppData" / "Roaming"
|
||||
scripts_dir = tmp_path / "проект" / "Scripts"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
python_exe = scripts_dir / "python.exe"
|
||||
transcribe_exe = scripts_dir / "transcribe.exe"
|
||||
python_exe.write_bytes(b"")
|
||||
transcribe_exe.write_bytes(b"")
|
||||
monkeypatch.setenv("APPDATA", str(appdata))
|
||||
monkeypatch.setattr(context_menu.sys, "executable", str(python_exe))
|
||||
|
||||
cmd_path = context_menu.install_menu()
|
||||
|
||||
assert context_menu.CMD_ENCODING == "oem"
|
||||
assert cmd_path.read_bytes() == (
|
||||
f'@echo off\r\n"{transcribe_exe}" %*\r\npause\r\n'.encode("oem")
|
||||
)
|
||||
|
||||
|
||||
def test_install_menu_overwrites_existing_file(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming"))
|
||||
monkeypatch.setattr(context_menu, "CMD_ENCODING", "utf-8")
|
||||
_prepare_exe(tmp_path, monkeypatch)
|
||||
|
||||
cmd_path = context_menu.install_menu()
|
||||
cmd_path.write_text("old", encoding="utf-8")
|
||||
|
||||
second_path = context_menu.install_menu()
|
||||
|
||||
assert second_path == cmd_path
|
||||
assert "old" not in cmd_path.read_text(encoding="utf-8")
|
||||
assert "%*" in cmd_path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_install_menu_creates_missing_sendto_dir(tmp_path, monkeypatch):
|
||||
appdata = tmp_path / "AppData" / "Roaming"
|
||||
monkeypatch.setenv("APPDATA", str(appdata))
|
||||
monkeypatch.setattr(context_menu, "CMD_ENCODING", "utf-8")
|
||||
_prepare_exe(tmp_path, monkeypatch)
|
||||
|
||||
cmd_path = context_menu.install_menu()
|
||||
|
||||
assert cmd_path.parent == appdata / "Microsoft" / "Windows" / "SendTo"
|
||||
assert cmd_path.parent.is_dir()
|
||||
|
||||
|
||||
def test_install_menu_oem_encoding_error_is_runtime_error(tmp_path, monkeypatch):
|
||||
appdata = tmp_path / "AppData" / "Roaming"
|
||||
cmd_path = appdata / "Microsoft" / "Windows" / "SendTo" / "Transcribe.cmd"
|
||||
monkeypatch.setenv("APPDATA", str(appdata))
|
||||
monkeypatch.setattr(context_menu, "CMD_ENCODING", "ascii")
|
||||
monkeypatch.setattr(
|
||||
context_menu,
|
||||
"get_transcribe_exe",
|
||||
lambda: tmp_path / "测试" / "transcribe.exe",
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="OEM"):
|
||||
context_menu.install_menu()
|
||||
|
||||
assert not cmd_path.exists()
|
||||
|
||||
|
||||
def test_uninstall_menu_removes_file_and_missing_is_not_error(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming"))
|
||||
monkeypatch.setattr(context_menu, "CMD_ENCODING", "utf-8")
|
||||
_prepare_exe(tmp_path, monkeypatch)
|
||||
cmd_path = context_menu.install_menu()
|
||||
|
||||
removed_path = context_menu.uninstall_menu()
|
||||
missing_path = context_menu.uninstall_menu()
|
||||
|
||||
assert removed_path == cmd_path
|
||||
assert not cmd_path.exists()
|
||||
assert missing_path is None
|
||||
|
||||
|
||||
def test_get_sendto_dir_requires_appdata(monkeypatch):
|
||||
monkeypatch.delenv("APPDATA", raising=False)
|
||||
|
||||
with pytest.raises(RuntimeError, match="APPDATA"):
|
||||
context_menu.get_sendto_dir()
|
||||
|
||||
|
||||
def test_get_transcribe_exe_requires_existing_exe(tmp_path, monkeypatch):
|
||||
scripts_dir = tmp_path / "venv" / "Scripts"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
python_exe = scripts_dir / "python.exe"
|
||||
python_exe.write_bytes(b"")
|
||||
monkeypatch.setattr(context_menu.sys, "executable", str(python_exe))
|
||||
|
||||
with pytest.raises(RuntimeError, match="uv sync"):
|
||||
context_menu.get_transcribe_exe()
|
||||
@@ -172,3 +172,141 @@ def test_write_transcript(tmp_path):
|
||||
out = tmp_path / "output.md"
|
||||
write_transcript("# Test content\n", out)
|
||||
assert out.read_text(encoding="utf-8") == "# Test content\n"
|
||||
|
||||
|
||||
def test_format_transcript_tail_gap_warning():
|
||||
result = TranscribeResult(
|
||||
segments=[Segment(start=0.0, end=60.0, text=" Фраза.")],
|
||||
language="ru",
|
||||
language_probability=0.95,
|
||||
duration=600.0,
|
||||
device_used="cpu",
|
||||
)
|
||||
|
||||
content = format_transcript(
|
||||
result,
|
||||
source_filename="tail.mp3",
|
||||
model_name="medium",
|
||||
device_info="CPU",
|
||||
language_mode="forced",
|
||||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||||
)
|
||||
|
||||
assert "возможна потеря хвоста" in content
|
||||
assert "транскрипт покрывает 01:00 из 10:00" in content
|
||||
|
||||
|
||||
def test_format_transcript_no_tail_gap_warning_for_small_gap():
|
||||
result = TranscribeResult(
|
||||
segments=[Segment(start=0.0, end=60.0, text=" Фраза.")],
|
||||
language="ru",
|
||||
language_probability=0.95,
|
||||
duration=179.99,
|
||||
device_used="cpu",
|
||||
)
|
||||
|
||||
content = format_transcript(
|
||||
result,
|
||||
source_filename="ok.mp3",
|
||||
model_name="medium",
|
||||
device_info="CPU",
|
||||
language_mode="forced",
|
||||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||||
)
|
||||
|
||||
assert "потеря хвоста" not in content
|
||||
|
||||
|
||||
def test_format_transcript_no_tail_gap_warning_for_exact_threshold():
|
||||
result = TranscribeResult(
|
||||
segments=[Segment(start=0.0, end=60.0, text=" Фраза.")],
|
||||
language="ru",
|
||||
language_probability=0.95,
|
||||
duration=180.0,
|
||||
device_used="cpu",
|
||||
)
|
||||
|
||||
content = format_transcript(
|
||||
result,
|
||||
source_filename="ok.mp3",
|
||||
model_name="medium",
|
||||
device_info="CPU",
|
||||
language_mode="forced",
|
||||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||||
)
|
||||
|
||||
assert "потеря хвоста" not in content
|
||||
|
||||
|
||||
def test_format_transcript_repetition_warning():
|
||||
result = TranscribeResult(
|
||||
segments=[
|
||||
Segment(start=10.0, end=11.0, text=" Повторяемая фраза."),
|
||||
Segment(start=11.0, end=12.0, text=" повторяемая фраза"),
|
||||
Segment(start=12.0, end=13.0, text=" «Повторяемая фраза»"),
|
||||
Segment(start=13.0, end=14.0, text=" повторяемая фраза…"),
|
||||
],
|
||||
language="ru",
|
||||
language_probability=0.95,
|
||||
duration=60.0,
|
||||
device_used="cpu",
|
||||
)
|
||||
|
||||
content = format_transcript(
|
||||
result,
|
||||
source_filename="repeat.mp3",
|
||||
model_name="medium",
|
||||
device_info="CPU",
|
||||
language_mode="forced",
|
||||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||||
)
|
||||
|
||||
assert "повторы в [00:10.00 - 00:14.00] (4×)" in content
|
||||
assert "возможны галлюцинации" in content
|
||||
|
||||
|
||||
def test_format_transcript_repetition_warning_uses_hours():
|
||||
result = TranscribeResult(
|
||||
segments=[
|
||||
Segment(start=3600.0, end=3601.0, text=" Повтор."),
|
||||
Segment(start=3601.0, end=3602.0, text=" повтор"),
|
||||
Segment(start=3602.0, end=3603.0, text=" повтор"),
|
||||
Segment(start=3603.0, end=3604.0, text=" повтор"),
|
||||
],
|
||||
language="ru",
|
||||
language_probability=0.95,
|
||||
duration=3700.0,
|
||||
device_used="cpu",
|
||||
)
|
||||
|
||||
content = format_transcript(
|
||||
result,
|
||||
source_filename="long-repeat.mp3",
|
||||
model_name="medium",
|
||||
device_info="CPU",
|
||||
language_mode="forced",
|
||||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||||
)
|
||||
|
||||
assert "повторы в [01:00:00.00 - 01:00:04.00] (4×)" in content
|
||||
|
||||
|
||||
def test_format_transcript_without_anomalies_has_no_warning_lines():
|
||||
result = TranscribeResult(
|
||||
segments=[Segment(start=0.0, end=60.0, text=" Обычная запись.")],
|
||||
language="ru",
|
||||
language_probability=0.95,
|
||||
duration=120.0,
|
||||
device_used="cpu",
|
||||
)
|
||||
|
||||
content = format_transcript(
|
||||
result,
|
||||
source_filename="ok.mp3",
|
||||
model_name="medium",
|
||||
device_info="CPU",
|
||||
language_mode="forced",
|
||||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||||
)
|
||||
|
||||
assert "Внимание" not in content
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
"""Tests for onnx-asr backend."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from local_transcriber.backends.onnx_asr import OnnxAsrBackend, MODEL_ALIASES
|
||||
from local_transcriber.types import Segment, TranscribeResult
|
||||
|
||||
|
||||
class FakeVadSegment:
|
||||
"""Mimics onnx-asr SegmentResult."""
|
||||
|
||||
def __init__(self, start, end, text):
|
||||
self.start = start
|
||||
self.end = end
|
||||
self.text = text
|
||||
|
||||
|
||||
class TestEnsureModelAvailable:
|
||||
def test_returns_model_id_for_gigaam(self):
|
||||
backend = OnnxAsrBackend()
|
||||
result = backend.ensure_model_available("gigaam-v3", "int8")
|
||||
assert result == "gigaam-v3-ctc"
|
||||
|
||||
def test_returns_model_id_for_parakeet(self):
|
||||
backend = OnnxAsrBackend()
|
||||
result = backend.ensure_model_available("parakeet-v3", "fp16")
|
||||
assert result == "nemo-parakeet-tdt-0.6b-v3"
|
||||
|
||||
def test_stores_compute_type(self):
|
||||
backend = OnnxAsrBackend()
|
||||
backend.ensure_model_available("gigaam-v3", "float32")
|
||||
assert backend._resolved_model_id == "gigaam-v3-ctc"
|
||||
assert backend.actual_compute_type == "float32"
|
||||
|
||||
|
||||
class TestCreateModel:
|
||||
def test_calls_load_model_with_correct_args(self, monkeypatch):
|
||||
"""Verify create_model passes correct args to onnx_asr.load_model."""
|
||||
calls = []
|
||||
|
||||
def fake_load_model(model=None, path=None, quantization=None,
|
||||
**kwargs):
|
||||
calls.append({
|
||||
"model": model, "path": path, "quantization": quantization,
|
||||
})
|
||||
return FakeAsrAdapter()
|
||||
|
||||
class FakeAsrAdapter:
|
||||
def with_vad(self, vad):
|
||||
return self
|
||||
|
||||
monkeypatch.setattr("onnx_asr.load_model", fake_load_model)
|
||||
|
||||
backend = OnnxAsrBackend()
|
||||
backend.actual_compute_type = "int8"
|
||||
model = backend.create_model("gigaam-v3-ctc", "onnx", "int8")
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["quantization"] == "int8"
|
||||
assert model is not None
|
||||
|
||||
def test_loads_silero_vad(self, monkeypatch):
|
||||
"""Verify Silero VAD is loaded and attached to model."""
|
||||
vad_calls = []
|
||||
|
||||
def fake_load_vad(model, **kwargs):
|
||||
vad_calls.append(model)
|
||||
return "fake_vad"
|
||||
|
||||
def fake_load_model(**kwargs):
|
||||
return FakeAsrAdapter()
|
||||
|
||||
class FakeAsrAdapter:
|
||||
def with_vad(self, vad):
|
||||
self._vad = vad
|
||||
return self
|
||||
|
||||
monkeypatch.setattr("onnx_asr.load_model", fake_load_model)
|
||||
monkeypatch.setattr("onnx_asr.load_vad", fake_load_vad)
|
||||
|
||||
backend = OnnxAsrBackend()
|
||||
model = backend.create_model("gigaam-v3-ctc", "onnx", "int8")
|
||||
|
||||
assert vad_calls == ["silero"]
|
||||
|
||||
def test_fp16_compute_type(self, monkeypatch):
|
||||
"""Verify fp16 compute_type is passed through."""
|
||||
calls = []
|
||||
|
||||
def fake_load_model(model=None, quantization=None, **kwargs):
|
||||
calls.append(quantization)
|
||||
return FakeAsrAdapter()
|
||||
|
||||
class FakeAsrAdapter:
|
||||
def with_vad(self, vad):
|
||||
return self
|
||||
|
||||
monkeypatch.setattr("onnx_asr.load_model", fake_load_model)
|
||||
monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kw: None)
|
||||
|
||||
backend = OnnxAsrBackend()
|
||||
backend.create_model("parakeet-v3", "onnx", "fp16")
|
||||
|
||||
assert calls == ["fp16"]
|
||||
|
||||
def test_float32_maps_to_none(self, monkeypatch):
|
||||
"""compute_type='float32' маппится в quantization=None (unquantized).
|
||||
|
||||
onnx-asr использует quantization как суффикс файла; для float32 нужен None,
|
||||
строка "float32" приведёт к попытке загрузить несуществующий файл.
|
||||
"""
|
||||
calls = []
|
||||
|
||||
def fake_load_model(model=None, quantization="MISSING", **kwargs):
|
||||
calls.append(quantization)
|
||||
return FakeAsrAdapter()
|
||||
|
||||
class FakeAsrAdapter:
|
||||
def with_vad(self, vad):
|
||||
return self
|
||||
|
||||
monkeypatch.setattr("onnx_asr.load_model", fake_load_model)
|
||||
monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kw: None)
|
||||
|
||||
backend = OnnxAsrBackend()
|
||||
backend.create_model("gigaam-v3-ctc", "onnx", "float32")
|
||||
|
||||
assert calls == [None]
|
||||
|
||||
def test_fp32_maps_to_none(self, monkeypatch):
|
||||
"""compute_type='fp32' тоже маппится в quantization=None."""
|
||||
calls = []
|
||||
|
||||
def fake_load_model(model=None, quantization="MISSING", **kwargs):
|
||||
calls.append(quantization)
|
||||
return FakeAsrAdapter()
|
||||
|
||||
class FakeAsrAdapter:
|
||||
def with_vad(self, vad):
|
||||
return self
|
||||
|
||||
monkeypatch.setattr("onnx_asr.load_model", fake_load_model)
|
||||
monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kw: None)
|
||||
|
||||
backend = OnnxAsrBackend()
|
||||
backend.create_model("gigaam-v3-ctc", "onnx", "fp32")
|
||||
|
||||
assert calls == [None]
|
||||
|
||||
def test_float16_alias_maps_to_fp16(self, monkeypatch):
|
||||
"""compute_type='float16' (CUDA-naming) маппится в onnx-asr 'fp16'."""
|
||||
calls = []
|
||||
|
||||
def fake_load_model(model=None, quantization=None, **kwargs):
|
||||
calls.append(quantization)
|
||||
return FakeAsrAdapter()
|
||||
|
||||
class FakeAsrAdapter:
|
||||
def with_vad(self, vad):
|
||||
return self
|
||||
|
||||
monkeypatch.setattr("onnx_asr.load_model", fake_load_model)
|
||||
monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kw: None)
|
||||
|
||||
backend = OnnxAsrBackend()
|
||||
backend.create_model("gigaam-v3-ctc", "onnx", "float16")
|
||||
|
||||
assert calls == ["fp16"]
|
||||
|
||||
def test_unknown_compute_type_raises(self, monkeypatch):
|
||||
"""Неподдерживаемый compute_type → ValueError, не silent fallback."""
|
||||
monkeypatch.setattr("onnx_asr.load_model", lambda **kw: None)
|
||||
monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kw: None)
|
||||
|
||||
backend = OnnxAsrBackend()
|
||||
with pytest.raises(ValueError, match="Неподдерживаемый compute_type"):
|
||||
backend.create_model("gigaam-v3-ctc", "onnx", "int8_float32")
|
||||
|
||||
|
||||
class TestTranscribe:
|
||||
def test_transcribe_collects_segments(self, monkeypatch, tmp_path):
|
||||
"""Verify transcribe maps VAD segments to project Segments."""
|
||||
wav_file = tmp_path / "test.wav"
|
||||
wav_file.write_bytes(b"fake audio")
|
||||
|
||||
audio_samples = [0.0] * 16000 # 1 second of silence
|
||||
|
||||
def fake_decode_audio(path, sampling_rate=16000):
|
||||
import numpy as np
|
||||
return np.array(audio_samples, dtype=np.float32)
|
||||
|
||||
class FakeModel:
|
||||
def recognize(self, waveform, sample_rate, language=None):
|
||||
yield FakeVadSegment(0.0, 1.0, "hello")
|
||||
yield FakeVadSegment(1.0, 2.5, "world")
|
||||
|
||||
monkeypatch.setattr("faster_whisper.decode_audio", fake_decode_audio)
|
||||
|
||||
backend = OnnxAsrBackend()
|
||||
backend.actual_compute_type = "int8"
|
||||
result = backend.transcribe(
|
||||
FakeModel(), wav_file, language=None,
|
||||
)
|
||||
|
||||
assert isinstance(result, TranscribeResult)
|
||||
assert len(result.segments) == 2
|
||||
assert result.segments[0] == Segment(start=0.0, end=1.0, text="hello")
|
||||
assert result.segments[1] == Segment(start=1.0, end=2.5, text="world")
|
||||
assert result.duration == 1.0 # 16000 samples / 16000 Hz
|
||||
|
||||
def test_transcribe_calls_on_segment(self, monkeypatch, tmp_path):
|
||||
"""Verify on_segment callback is invoked per segment."""
|
||||
wav_file = tmp_path / "test.wav"
|
||||
wav_file.write_bytes(b"fake audio")
|
||||
|
||||
def fake_decode_audio(path, sampling_rate=16000):
|
||||
import numpy as np
|
||||
return np.array([0.0] * 16000, dtype=np.float32)
|
||||
|
||||
segments_captured = []
|
||||
|
||||
class FakeModel:
|
||||
def recognize(self, waveform, sample_rate, language=None):
|
||||
yield FakeVadSegment(0.0, 2.0, "one")
|
||||
yield FakeVadSegment(2.0, 4.0, "two")
|
||||
|
||||
monkeypatch.setattr("faster_whisper.decode_audio", fake_decode_audio)
|
||||
|
||||
backend = OnnxAsrBackend()
|
||||
result = backend.transcribe(
|
||||
FakeModel(), wav_file, language=None,
|
||||
on_segment=lambda s: segments_captured.append(s),
|
||||
)
|
||||
|
||||
assert len(segments_captured) == 2
|
||||
assert segments_captured[0].text == "one"
|
||||
assert segments_captured[1].text == "two"
|
||||
|
||||
def test_transcribe_passes_language(self, monkeypatch, tmp_path):
|
||||
"""Verify language is passed to recognize()."""
|
||||
wav_file = tmp_path / "test.wav"
|
||||
wav_file.write_bytes(b"fake audio")
|
||||
|
||||
def fake_decode_audio(path, sampling_rate=16000):
|
||||
import numpy as np
|
||||
return np.array([0.0] * 16000, dtype=np.float32)
|
||||
|
||||
lang_received = []
|
||||
|
||||
class FakeModel:
|
||||
def recognize(self, waveform, sample_rate, language=None):
|
||||
lang_received.append(language)
|
||||
yield FakeVadSegment(0.0, 1.0, "text")
|
||||
|
||||
monkeypatch.setattr("faster_whisper.decode_audio", fake_decode_audio)
|
||||
|
||||
backend = OnnxAsrBackend()
|
||||
backend.transcribe(FakeModel(), wav_file, language="ru")
|
||||
|
||||
assert lang_received == ["ru"]
|
||||
|
||||
def test_transcribe_empty_audio(self, monkeypatch, tmp_path):
|
||||
"""Verify zero segments for silent audio."""
|
||||
wav_file = tmp_path / "test.wav"
|
||||
wav_file.write_bytes(b"fake audio")
|
||||
|
||||
def fake_decode_audio(path, sampling_rate=16000):
|
||||
import numpy as np
|
||||
return np.array([0.0] * 16000, dtype=np.float32)
|
||||
|
||||
class FakeModel:
|
||||
def recognize(self, waveform, sample_rate, language=None):
|
||||
# No segments yielded
|
||||
if False:
|
||||
yield
|
||||
|
||||
monkeypatch.setattr("faster_whisper.decode_audio", fake_decode_audio)
|
||||
|
||||
backend = OnnxAsrBackend()
|
||||
result = backend.transcribe(FakeModel(), wav_file, language=None)
|
||||
|
||||
assert len(result.segments) == 0
|
||||
assert result.language == "unknown"
|
||||
assert result.duration == 1.0
|
||||
|
||||
|
||||
class TestBackendRegistration:
|
||||
def test_get_backend_returns_onnx_backend(self):
|
||||
from local_transcriber.backends import get_backend
|
||||
backend = get_backend("onnx")
|
||||
assert isinstance(backend, OnnxAsrBackend)
|
||||
|
||||
|
||||
class TestModelAliases:
|
||||
def test_gigaam_v3_resolves(self):
|
||||
backend = OnnxAsrBackend()
|
||||
result = backend._resolve_model("gigaam-v3")
|
||||
assert result == "gigaam-v3-ctc"
|
||||
|
||||
def test_parakeet_v3_resolves(self):
|
||||
backend = OnnxAsrBackend()
|
||||
result = backend._resolve_model("parakeet-v3")
|
||||
assert result == "nemo-parakeet-tdt-0.6b-v3"
|
||||
|
||||
def test_raw_name_passes_through(self):
|
||||
backend = OnnxAsrBackend()
|
||||
result = backend._resolve_model("nemo-canary-1b-v2")
|
||||
assert result == "nemo-canary-1b-v2"
|
||||
|
||||
def test_unknown_alias_raises(self):
|
||||
backend = OnnxAsrBackend()
|
||||
with pytest.raises(ValueError, match="Неподдерживаемая модель"):
|
||||
backend._resolve_model("nonexistent-model")
|
||||
@@ -0,0 +1,125 @@
|
||||
import pytest
|
||||
|
||||
from local_transcriber.quality import (
|
||||
REPETITION_MIN_LEN,
|
||||
TAIL_GAP_WARN_S,
|
||||
_normalize,
|
||||
find_repetition_blocks,
|
||||
tail_gap,
|
||||
)
|
||||
from local_transcriber.types import Segment, TranscribeResult
|
||||
|
||||
|
||||
def _result(segments, duration):
|
||||
return TranscribeResult(
|
||||
segments=segments,
|
||||
language="ru",
|
||||
language_probability=0.95,
|
||||
duration=duration,
|
||||
device_used="cpu",
|
||||
)
|
||||
|
||||
|
||||
def _segments(texts, start=0.0):
|
||||
return [
|
||||
Segment(start=start + index, end=start + index + 1.0, text=text)
|
||||
for index, text in enumerate(texts)
|
||||
]
|
||||
|
||||
|
||||
def test_tail_gap_returns_positive_gap():
|
||||
result = _result([Segment(0.0, 10.0, "Текст")], duration=42.0)
|
||||
|
||||
assert tail_gap(result) == 32.0
|
||||
|
||||
|
||||
def test_tail_gap_empty_segments_returns_zero():
|
||||
assert tail_gap(_result([], duration=42.0)) == 0.0
|
||||
|
||||
|
||||
def test_tail_gap_negative_gap_returns_zero():
|
||||
result = _result([Segment(0.0, 43.0, "Текст")], duration=42.0)
|
||||
|
||||
assert tail_gap(result) == 0.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gap", [119.99, 120.0])
|
||||
def test_tail_gap_boundary_does_not_warn(gap):
|
||||
result = _result([Segment(0.0, 10.0, "Текст")], duration=10.0 + gap)
|
||||
|
||||
assert tail_gap(result) <= TAIL_GAP_WARN_S
|
||||
|
||||
|
||||
def test_tail_gap_boundary_warns_above_threshold():
|
||||
result = _result([Segment(0.0, 10.0, "Текст")], duration=130.01)
|
||||
|
||||
assert tail_gap(result) > TAIL_GAP_WARN_S
|
||||
|
||||
|
||||
def test_normalize_removes_case_punctuation_and_collapses_spaces():
|
||||
assert _normalize(' «ПРИВЕТ…» — (мир) [тест] <да> ') == "привет мир тест да"
|
||||
assert _normalize("раз–два-три: да; нет!") == "раздватри да нет"
|
||||
|
||||
|
||||
def test_normalize_punctuation_only_returns_empty():
|
||||
assert _normalize('.,!?…:;—–-"\'«»()[]<> ') == ""
|
||||
|
||||
|
||||
def test_find_repetition_blocks_four_long_segments_with_normalization():
|
||||
segments = _segments(["Повтор!", "повтор", "«ПОВТОР»", "повтор…"])
|
||||
|
||||
blocks = find_repetition_blocks(segments)
|
||||
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0].start == 0.0
|
||||
assert blocks[0].end == 4.0
|
||||
assert blocks[0].count == 4
|
||||
assert blocks[0].text == "Повтор!"
|
||||
|
||||
|
||||
def test_find_repetition_blocks_three_long_segments_is_empty():
|
||||
assert find_repetition_blocks(_segments(["Повтор", "повтор", "повтор"])) == []
|
||||
|
||||
|
||||
def test_find_repetition_blocks_length_boundary():
|
||||
assert find_repetition_blocks(_segments(["пять5"] * 4)) == []
|
||||
|
||||
blocks = find_repetition_blocks(_segments(["шесть6"] * 4))
|
||||
|
||||
assert len("шесть6") == REPETITION_MIN_LEN
|
||||
assert len(blocks) == 1
|
||||
|
||||
|
||||
def test_find_repetition_blocks_short_text_boundary():
|
||||
assert find_repetition_blocks(_segments(["Ага."] * 9)) == []
|
||||
|
||||
blocks = find_repetition_blocks(_segments(["Ага."] * 10))
|
||||
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0].count == 10
|
||||
|
||||
|
||||
def test_find_repetition_blocks_empty_normalized_text_is_ignored():
|
||||
assert find_repetition_blocks(_segments([":", "", " "] * 10)) == []
|
||||
|
||||
|
||||
def test_find_repetition_blocks_two_separate_runs():
|
||||
segments = [
|
||||
*_segments(["Первый повтор"] * 4),
|
||||
Segment(10.0, 11.0, "Разрыв"),
|
||||
*_segments(["Второй повтор"] * 4, start=20.0),
|
||||
]
|
||||
|
||||
blocks = find_repetition_blocks(segments)
|
||||
|
||||
assert len(blocks) == 2
|
||||
assert blocks[0].text == "Первый повтор"
|
||||
assert blocks[1].text == "Второй повтор"
|
||||
|
||||
|
||||
def test_find_repetition_blocks_empty_list_is_empty():
|
||||
assert find_repetition_blocks([]) == []
|
||||
|
||||
|
||||
def test_find_repetition_blocks_single_segment_is_empty():
|
||||
assert find_repetition_blocks([Segment(0.0, 1.0, "Повтор")]) == []
|
||||
@@ -321,6 +321,34 @@ def test_load_model_returns_backend_and_path(mock_get_backend):
|
||||
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 ===
|
||||
|
||||
|
||||
@@ -527,6 +555,36 @@ def test_transcribe_file_openvino_gpu_midstream_fallback(mock_get_backend):
|
||||
assert tfr.model_path == "/mock/cpu/model"
|
||||
|
||||
|
||||
@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."""
|
||||
|
||||
@@ -301,6 +301,7 @@ source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "faster-whisper" },
|
||||
{ name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
{ name = "onnx-asr", extra = ["cpu", "hub"] },
|
||||
{ name = "openvino-genai", marker = "(platform_machine == 'AMD64' and sys_platform != 'darwin') or (platform_machine == 'x86_64' and sys_platform != 'darwin')" },
|
||||
{ name = "rich" },
|
||||
{ name = "socksio" },
|
||||
@@ -317,6 +318,7 @@ dev = [
|
||||
requires-dist = [
|
||||
{ name = "faster-whisper", specifier = ">=1.2.1" },
|
||||
{ name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=12.4" },
|
||||
{ name = "onnx-asr", extras = ["cpu", "hub"], specifier = ">=0.11.0,<0.12.0" },
|
||||
{ name = "openvino-genai", marker = "(platform_machine == 'AMD64' and sys_platform != 'darwin') or (platform_machine == 'x86_64' and sys_platform != 'darwin')", specifier = ">=2025.0" },
|
||||
{ name = "rich" },
|
||||
{ name = "socksio", specifier = ">=1.0.0" },
|
||||
@@ -512,6 +514,28 @@ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "onnx-asr"
|
||||
version = "0.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/78/f6/b154881761a593312f509522f99542acffa2516f7a1df6ddf5660ad4a162/onnx_asr-0.11.0.tar.gz", hash = "sha256:57ad8d9571dc17db95f0daf9ba432b9472383de320c610735850e56b5375a37d", size = 43665, upload-time = "2026-03-23T02:30:57.349Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/04/bdffd682cc38b43144b6528186c80451f219a05e3fd0eb331a548f455b9a/onnx_asr-0.11.0-py3-none-any.whl", hash = "sha256:142d8b3ce7716684992826a269304f5ce9cf1c0fe704b751358e223f45d2a5cf", size = 138349, upload-time = "2026-03-23T02:30:58.566Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
cpu = [
|
||||
{ name = "onnxruntime" },
|
||||
]
|
||||
hub = [
|
||||
{ name = "huggingface-hub" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "onnxruntime"
|
||||
version = "1.24.3"
|
||||
|
||||
Reference in New Issue
Block a user