26 Commits
Author SHA1 Message Date
Dmitriy DementievandClaude Fable 5 5a7fc0d613 docs(backlog): направления по структуре транскрипта из внешнего ревью
- Зачем:
  - внешнее сравнение с облачным Hypescribe показало: главный разрыв локальной транскрипции — не качество распознавания, а структура (спикеры 2/10 против 8/10); без диаризации MoM не собрать.
- Что:
  - новый раздел «Структура транскрипта»: диаризация как опциональный пост-процессинг (sherpa-onnx vs pyannote, нужен ADR), ручка нарезки абзацев в formatter, словарь замен терминов как запасной план после бенчмарка RNN-T.
  - зафиксировано вне ядра CLI: LLM-чистка и сопоставление имён спикеров.
- Проверка:
  - docs-only, тесты не затронуты.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:44:24 +03:00
Dmitriy DementievandClaude Fable 5 b1dfd9dcca feat(quality): предупреждения о возможной потере содержания транскрипта
- Зачем:
  - транскрипция может тихо терять содержание: блоки галлюцинированных повторов и обрыв распознавания до конца файла выглядят как законченный транскрипт (класс ошибок из ADR-006), и читатель об этом не узнаёт.
- Что:
  - новый модуль quality.py: детектор разрыва в хвосте (строго >120 с) и детектор блоков повторяющихся сегментов (серия >=4 для длинного текста, >=10 для короткого вроде «ага»).
  - предупреждения жёлтым в консоли (single и batch) и строками «Внимание» в шапке markdown-транскрипта; _format_duration переименована в публичную format_duration.
  - 27 новых тестов: границы порогов, нормализация текста, шапка formatter, точные строки CLI-сообщений с обрезкой «(+ ещё N)».
- Проверка:
  - uv run pytest — 218 passed, 1 skipped.
  - ручной прогон записи 23:01 (gigaam-v3/onnx) — транскрипт полный, предупреждений нет.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:21:07 +03:00
Dmitriy DementievandClaude Fable 5 650e7580e5 chore(repo): убран посторонний app.py, идея чанкования сохранена в backlog
- Зачем:
  - в дереве с апреля лежал некоммиченный app.py (референсный Parakeet-сервер, не часть CLI) и служебный каталог .qwen/ — мусор в git status.
- Что:
  - удалён app.py; его единственная ценная идея (чанкование длинных файлов по паузам через ffmpeg silencedetect) описана в docs/backlog.md с рабочими константами.
  - .qwen/ добавлен в .gitignore (по аналогии с .codex).
- Проверка:
  - git status — дерево чистое, untracked-файлов нет.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:49:45 +03:00
Dmitriy DementievandClaude Fable 5 df204e5ae4 chore: .scratch/ добавлен в .gitignore
- Зачем:
  - рабочие файлы конвейера (задачи-файлы) не должны попадать в дерево.
- Что:
  - .scratch/ в .gitignore.
- Проверка:
  - git status не показывает .scratch/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:33:02 +03:00
Dmitriy DementievandClaude Fable 5 9307486ceb docs: фича SendTo-меню отражена в README и AGENTS.md
- Зачем:
  - фича --install-menu была описана только в глубине README; верхний
    список возможностей и инструкция для AI-ассистентов о ней не знали.
- Что:
  - пункт «Из проводника Windows» в списке возможностей README со ссылкой
    на раздел; команды раздела приведены к tool-установке (без uv run).
  - строка context_menu.py в схеме архитектуры AGENTS.md.
- Проверка:
  - вычитка README, переход по якорю раздела.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:32:47 +03:00
Dmitriy DementievandClaude Fable 5 f18ad6ea2a feat(cli): добавлен пункт Transcribe в меню «Отправить» проводника
- Зачем:
  - запуск транскрипции правой кнопкой из проводника Windows без
    терминала, для пользователей без прав администратора.
- Что:
  - новый модуль context_menu.py: установка/удаление Transcribe.cmd
    в папке SendTo (OEM-кодировка, CRLF, без реестра).
  - флаги --install-menu / --uninstall-menu в cli.py с ранней
    валидацией до load_config; files стал необязательным аргументом.
  - раздел в README.md: установка, использование, ручное удаление
    через shell:sendto, известные ограничения.
- Проверка:
  - uv run pytest — 191 passed, 1 skipped.
  - uv run transcribe --install-menu и правый клик → Отправить →
    Transcribe на файле с кириллицей в имени.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:29:07 +03:00
ddadmin 966efc3bfe docs: добавлены AGENTS.md и CLAUDE.md для AI-ассистентов 2026-04-26 20:05:12 +03:00
ddadmin e41600510b add .codex to gitignore 2026-04-26 19:54:23 +03:00
ddadminandClaude Opus 4.7 0059e28f77 fix(deps): ограничить onnx-asr верхней границей <0.12.0
onnx-asr — pre-1.0 библиотека (текущая 0.11.x). Без верхней границы любой
minor-апдейт (0.12.x) или major (1.0.0) подгрузится автоматически и может
сломать совместимость API: для pre-1.0 SemVer не гарантирует обратную
совместимость даже на minor-bumps. Уже наблюдали это в первой итерации:
при smoke-тесте обнаружили расхождение API между ожидаемой и реальной
сигнатурой (cpu_preprocessing был removed, start_ts/end_ts → start/end).

Ограничиваем до compatible-release: >=0.11.0,<0.12.0. При появлении 0.12
обновим осознанно после проверки совместимости.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:34:57 +03:00
ddadminandClaude Opus 4.7 1c779be139 Merge branch 'experiment/parakeet-backend-opencode'
Эксперимент с DeepSeek V4 Pro: добавление onnx-asr бэкенда (Parakeet/GigaAM/
FastConformer) с независимой валидацией качества через agent-judge.

Реализация:
- backends/onnx_asr.py — третий pluggable backend через onnxruntime + Silero VAD.
- --device onnx с моделями gigaam-v3 (рекомендуется для русского) и parakeet-v3.
- _normalize_quantization() — маппинг compute_type → onnx-asr quantization
  (int8/fp16 pass-through, float16→fp16, float32/fp32→None, остальные→ValueError).
- DEVICE_DEFAULTS для onnx, _VALID_DEVICES расширен.

Документация:
- ADR-006 — итоги: GigaAM v3 как best-CPU choice для русских встреч, Parakeet
  непригоден для русского (воспроизведены три класса проблем из ADR-005), новый
  класс ошибок Whisper medium на длинных файлах с тихими фрагментами.
- backlog.md — устойчивый список будущих экспериментов (GigaAM v3 RNN-T и др.).

Контекст эксперимента:
- ADR-005 (Opus, master) отклонил Parakeet на двух 15-мин файлах с тихим
  микрофоном. ADR-006 повторяет на 22-81-мин файлах + добавляет GigaAM.
- Реализация прошла три фазы валидации: smoke-test (4 mock-API расхождения
  → fix), agent-judge на 9 транскриптах (подтверждение проблем Parakeet,
  обнаружение Whisper-галлюцинаций), adversarial-review через GPT-5.5
  (compute_type=float32 ломал onnx-загрузку → fix + 4 теста).

Не включено в merge: docs/superpowers/specs/ и plans/ от DeepSeek (stale
assumptions, итоговый канон в ADR-006; полные документы — в истории ветки).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:33:30 +03:00
ddadminandClaude Opus 4.7 9cfa437b33 fix(onnx-asr): корректный маппинг compute_type в onnx-asr quantization
При adversarial review (gpt-5.5) обнаружено: пользователь с config
compute_type = "float32" и --device onnx падал на старте.
HARDCODED_DEFAULTS["compute_type"] = "float32", apply_device_defaults не
заменяет значение если оно есть в config — старая проверка
(compute_type in ("int8", "fp16", "float32")) пропускала "float32"
дальше как onnx_asr quantization, что заставляло искать несуществующий
файл с суффиксом _float32.

- _normalize_quantization() — explicit маппинг в onnx-asr quantization.
- float32/fp32 → None (unquantized loading в onnx-asr — это None, не строка).
- float16 → fp16 (CUDA-naming → onnx-asr-naming).
- int8/fp16 → pass-through.
- Неизвестные compute_type (например, int8_float32 от CTranslate2) → ValueError
  вместо silent fallback на int8 — пользователь раньше получал не ту
  модель без предупреждения.

4 новых теста: float32→None, fp32→None, float16→fp16, unknown→raises.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:06:22 +03:00
ddadminandClaude Opus 4.7 0c3a67c4ea docs: добавлен backlog.md — устойчивый список будущих экспериментов
В проекте не было общего файла для будущих идей: docs/plan.md — историческая
летопись (всё [x]), docs/gpu.md — потенциальные направления привязаны к GPU/CPU
производительности, открытые вопросы из ADR легко теряются среди следующих ADR.

Создан docs/backlog.md как канонический хаб для будущих экспериментов
с обоснованием, цифрами и ссылками на источники. Первые записи:

- GigaAM v3 RNN-T — потенциальная замена CTC-дефолта (WER 2.6% vs 13.2%
  на сложных текстах, e2e_rnnt c пунктуацией, 70:30 vs Whisper-large-v3 LLM-judge)
- Canary 1B — multilingual + пунктуация на CPU
- Whisper medium галлюцинации на длинных файлах (chunk_length / pipeline-фильтры)
- Качественный pipeline для OpenVINO (давно зафиксировано в gpu.md)
- Включение onnx в --device auto после стабилизации

Все направления связаны ссылками на источники (ADR-006, gpu.md, публикации
SberDevices), чтобы при возврате к работе не пришлось воспроизводить контекст.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:48:36 +03:00
ddadminandClaude Opus 4.7 3b7eb603ae docs(adr): ADR-006 onnx-asr — независимая валидация через agent-judge
Переименовано из 005 в 006: на master уже есть ADR-005-parakeet-evaluation
от прошлого эксперимента (Opus, отклонение Parakeet), нумерация бы конфликтовала.

Качество замерено независимым agent-judge'ем по методологии ADR-005
на 9 транскриптах (3 файла × 3 backend), а не субъективными пометками
"Отлично/Хорошо" на одном файле, как в первой версии. Артефакты транскриптов
лежат в /mnt/c/ddmitry/Videos/OBS/ для верификации.

Подтверждены три класса проблем Parakeet из ADR-005 (Mm-hmm-редукция,
иноязычные вставки, искажение IT-терминов) на новом наборе файлов
с конкретными цитатами и таймкодами.

Обнаружен новый класс ошибок Whisper medium: галлюцинации на длинных
файлах с тихими фрагментами (до 17 минут потеряно на одном файле,
многоминутные повторы, приписывание несуществующих имён). Не описано
в ADR-005, потому что там были 15-минутные отрывки.

GigaAM v3 подтверждён как лучший backend для русских встреч на CPU
(summary utility 4/5 на всех файлах, единственный без потерь содержания).
Рекомендация в README уточнена: parakeet-v3 для русского не подходит,
openvino-cpu medium — только для встреч ≤30 мин с равномерной громкостью.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:35:04 +03:00
ddadmin 5990d85a58 chore: удалён скрипт сравнения (не пригодился)
- Зачем:
  - сравнение сделано вручную, скрипт не использовался.
- Что:
  - удалён scripts/compare_backends.py.
- Проверка:
  - uv run pytest -q (172 passed).
2026-04-25 23:17:30 +03:00
ddadmin 503f2a3732 docs(readme): уточнены рекомендации по onnx-моделям
- Зачем:
  - в README не было рекомендаций по выбору между gigaam-v3 и parakeet-v3.
- Что:
  - добавлена таблица ONNX-моделей с RTFx, языками и пунктуацией.
  - пример parakeet-v3 изменён на --language ru.
  - рекомендации: gigaam-v3 для скорости, parakeet-v3 для пунктуации.
- Проверка:
  - uv run pytest -q (172 passed).
2026-04-25 23:16:13 +03:00
ddadmin 854bf55145 docs: задокументировать onnx-бэкенд в README, CLI-help и ADR-005
- Зачем:
  - пользователь должен знать о --device onnx и поддерживаемых моделях.
- Что:
  - CLI help: добавлен onnx в список устройств.
  - README: секция примеров, таблица платформ, device-aware дефолты.
  - ADR-005: результаты parakeet-v3 --language ru, уточнённые рекомендации.
- Проверка:
  - uv run pytest -q (172 passed).
  - uv run transcribe --help показывает onnx.
2026-04-25 23:13:41 +03:00
ddadmin 53142fd341 docs(adr): ADR-005 — результаты эксперимента с onnx-asr бэкендом
- Зачем:
  - задокументировать результаты сравнения gigaam-v3 vs OpenVINO на CPU.
- Что:
  - таблица скорости по 3 файлам (3-5x ускорение).
  - сравнение качества русской речи с GPU faster-whisper.
  - решение: оставить бэкенд как экспериментальный (--device onnx).
- Проверка:
  - просмотр docs/adr/005-onnx-asr-backend.md.
2026-04-25 22:31:51 +03:00
ddadmin b1cbdc3e0b fix(onnx-asr): исправлены баги интеграции после smoke-теста
- Зачем:
  - при smoke-тесте выявлены расхождения между onnx-asr API и мок-тестами.
- Что:
  - убран deprecated cpu_preprocessing из load_model.
  - sample_rate передан как keyword-аргумент в recognize().
  - исправлены имена полей SegmentResult: start_ts→start, end_ts→end.
  - добавлен device "onnx" в DEVICE_DEFAULTS и _VALID_DEVICES.
- Проверка:
  - uv run pytest -q (172 passed).
  - uv run transcribe file.mp4 --device onnx --model gigaam-v3.
2026-04-25 21:48:54 +03:00
ddadmin 7d39ab908b test: добавить скрипт сравнения onnx-asr и OpenVINO бэкендов
- Зачем:
  - Экспериментальное сравнение скорости и качества транскрипции на реальных аудиофайлах.
- Что:
  - скрипт compare_backends.py запускает транскрипцию одного файла через gigaam-v3, parakeet-v3 и OpenVINO medium.
  - выводит время, количество сегментов и превью текста для каждого.
- Проверка:
  - python scripts/compare_backends.py /path/to/audio.mp3
2026-04-25 21:29:08 +03:00
ddadmin 79dbd170ce feat(onnx-asr): зарегистрировать бэкенд для --device onnx
- Зачем:
  - Бэкенд должен быть доступен через CLI по --device onnx.
- Что:
  - в backends/__init__.py добавлена ветка device == "onnx" с ленивым импортом OnnxAsrBackend.
  - при отсутствии onnx-asr выдаётся понятная ошибка.
  - добавлен тест TestBackendRegistration.
- Проверка:
  - uv run pytest -v (172 passed)
2026-04-25 21:25:28 +03:00
ddadmin 9e04dc8c25 feat(onnx-asr): реализовать transcribe с сегментацией через VAD
- Зачем:
  - Основной метод бэкенда — транскрипция аудиофайла в сегменты с временными метками.
- Что:
  - метод transcribe декодирует аудио через faster_whisper.decode_audio, затем вызывает model.recognize() с VAD-сегментацией.
  - каждый VAD-сегмент преобразуется в проектную структуру Segment.
  - поддержка колбэков on_segment, on_status.
  - написаны 4 теста: сбор сегментов, вызов on_segment, передача языка, обработка пустого аудио.
- Проверка:
  - uv run pytest tests/test_onnx_asr.py -v (14 passed)
2026-04-25 21:24:31 +03:00
ddadmin d9c9aefdb3 feat(onnx-asr): реализовать create_model с Silero VAD
- Зачем:
  - onnx-asr модель должна создаваться с квантизацией и VAD для разбивки аудио на сегменты.
- Что:
  - метод create_model вызывает onnx_asr.load_model с квантизацией и cpu_preprocessing=True.
  - подгружается Silero VAD, прикрепляется к модели через with_vad.
  - написаны 3 теста: проверка аргументов load_model, загрузка VAD, передача fp16.
- Проверка:
  - uv run pytest tests/test_onnx_asr.py -v
2026-04-25 21:22:02 +03:00
ddadmin f25a546754 feat(onnx-asr): реализовать ensure_model_available
- Зачем:
  - Бэкенд должен резолвить алиасы моделей и сохранять compute_type для дальнейшего использования.
- Что:
  - добавлен конструктор __init__ с полями actual_compute_type, _resolved_model_id, _vad.
  - метод ensure_model_available валидирует алиас и возвращает идентификатор модели onnx-asr.
  - написаны 3 теста на резолвинг и сохранение compute_type.
- Проверка:
  - uv run pytest tests/test_onnx_asr.py -v
2026-04-25 21:20:35 +03:00
ddadmin a3d0213cb3 feat(onnx-asr): добавить скелет бэкенда с разрешением алиасов моделей
- Зачем:
  - onnx-asr бэкенд должен распознавать короткие алиасы (gigaam-v3, parakeet-v3) и raw-имена (nemo-canary-1b-v2).
- Что:
  - создан файл backends/onnx_asr.py с MODEL_ALIASES и классом OnnxAsrBackend.
  - метод _resolve_model: преобразует алиасы, пропускает raw-имена, выдаёт ValueError для неизвестных.
  - написаны 4 теста на разрешение алиасов.
- Проверка:
  - uv run pytest tests/test_onnx_asr.py::TestModelAliases -v
2026-04-25 21:19:33 +03:00
ddadmin b5c1da24ce build(deps): добавлена зависимость onnx-asr[cpu,hub]>=0.11.0
- Зачем:
  - onnx-asr бэкенд для транскрипции (Parakeet/GigaAM) даёт 30-90x realtime на CPU.
- Что:
  - добавлен пакет onnx-asr[cpu,hub]>=0.11.0 в зависимости проекта.
  - uv.lock обновлён.
- Проверка:
  - uv run python -c "import onnx_asr; print(onnx_asr.__version__)"
2026-04-25 21:17:24 +03:00
ddadmin 82596187a4 docs(superpowers): добавлены spec и план для onnx-asr бэкенда
- Зачем:
  - задизайнить эксперимент с Parakeet/GigaAM через onnx-asr как третий бэкенд.
- Что:
  - spec: архитектура, модель-алиасы (gigaam-v3, parakeet-v3), API, регистрация.
  - plan: 8 задач TDD: зависимость → скелет → ensure → create → transcribe → регистрация.
- Проверка:
  - gh pr diff, ревью spec/plan.
2026-04-25 21:09:43 +03:00
20 changed files with 1842 additions and 14 deletions
+3
View File
@@ -4,3 +4,6 @@ __pycache__/
.mypy_cache/ .mypy_cache/
dist/ dist/
*.pyc *.pyc
.codex
.qwen/
.scratch/
+77
View File
@@ -0,0 +1,77 @@
# 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)
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+54 -8
View File
@@ -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 с таймкодами** — удобен для суммаризации ИИ - **Markdown с таймкодами** — удобен для суммаризации ИИ
- **Аудио и видео** — mp3, wav, mp4, mkv и [другие форматы](#поддерживаемые-форматы) - **Аудио и видео** — mp3, wav, mp4, mkv и [другие форматы](#поддерживаемые-форматы)
@@ -114,6 +115,12 @@ transcribe podcast.wav --model large-v3 --compute-type float16
# Максимальное качество на Intel GPU # Максимальное качество на Intel GPU
transcribe podcast.wav --model large-v3 --device openvino-gpu transcribe podcast.wav --model large-v3 --device openvino-gpu
# Максимальная скорость на 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 transcribe interview.m4a --output result.md
``` ```
@@ -138,6 +145,31 @@ transcribe *.mp4 --force
- При ошибке в одном файле остальные продолжают обрабатываться - При ошибке в одном файле остальные продолжают обрабатываться
- `--output` несовместим с несколькими файлами - `--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 ### Опции CLI
| Опция | Сокращение | По умолчанию | Описание | | Опция | Сокращение | По умолчанию | Описание |
@@ -145,8 +177,8 @@ transcribe *.mp4 --force
| `--model` | `-m` | `medium` | Модель Whisper | | `--model` | `-m` | `medium` | Модель Whisper |
| `--language` | `-l` | `ru` | Язык (ru, en, auto и др.) | | `--language` | `-l` | `ru` | Язык (ru, en, auto и др.) |
| `--output` | `-o` | `<файл>-transcript.md` | Путь к выходному файлу | | `--output` | `-o` | `<файл>-transcript.md` | Путь к выходному файлу |
| `--device` | `-d` | `auto` | Устройство (auto, cpu, cuda, openvino, openvino-gpu, openvino-cpu) | | `--device` | `-d` | `auto` | Устройство (auto, cpu, cuda, openvino, openvino-gpu, openvino-cpu, onnx) |
| `--compute-type` | — | float16 (CUDA) / int8 (OpenVINO GPU/CPU) / float32 (CPU) | Тип вычислений | | `--compute-type` | — | float16 (CUDA) / int8 (OpenVINO/ONNX) / float32 (CPU) | Тип вычислений |
| `--threads` | `-t` | 0 (авто) | Потоки CPU (рекомендуется = число физ. ядер) | | `--threads` | `-t` | 0 (авто) | Потоки CPU (рекомендуется = число физ. ядер) |
| `--force` | `-f` | — | Перезаписать существующие транскрипты | | `--force` | `-f` | — | Перезаписать существующие транскрипты |
| `--verbose` | `-v` | — | Подробный вывод | | `--verbose` | `-v` | — | Подробный вывод |
@@ -158,6 +190,7 @@ transcribe *.mp4 --force
| CPU | ✅ | ✅ | ✅ | | CPU | ✅ | ✅ | ✅ |
| OpenVINO (x86 CPU) | ✅ авто | — | ✅ авто | | OpenVINO (x86 CPU) | ✅ авто | — | ✅ авто |
| OpenVINO (Intel GPU) | ✅ авто | — | ✅ авто | | OpenVINO (Intel GPU) | ✅ авто | — | ✅ авто |
| ONNX (CPU) | ✅ явно | ✅ явно | ✅ явно |
| GPU (NVIDIA) | ✅ авто | — | ✅ (нужен CUDA 12) | | GPU (NVIDIA) | ✅ авто | — | ✅ (нужен CUDA 12) |
<details> <details>
@@ -210,11 +243,11 @@ language = "en"
Дефолты зависят от устройства: Дефолты зависят от устройства:
| Параметр | CUDA | OpenVINO (GPU) | OpenVINO (CPU) | CPU | | Параметр | CUDA | OpenVINO (GPU) | OpenVINO (CPU) | ONNX | CPU |
|----------|------|----------------|----------------|-----| |----------|------|----------------|----------------|------|-----|
| model | medium | medium | medium | medium | | model | medium | medium | medium | gigaam-v3 | medium |
| compute_type | float16 | int8 | int8 | float32 | | compute_type | float16 | int8 | int8 | int8 | float32 |
| language | ru | ru | ru | ru | | language | ru | ru | ru | ru | ru |
## Модели и GPU ## Модели и GPU
@@ -222,6 +255,8 @@ language = "en"
- **По умолчанию:** `medium` — хороший баланс скорости и качества - **По умолчанию:** `medium` — хороший баланс скорости и качества
- **Макс. качество (NVIDIA):** `large-v3` + `--compute-type float16` - **Макс. качество (NVIDIA):** `large-v3` + `--compute-type float16`
- **Макс. качество (Intel GPU):** `large-v3` + `--device openvino-gpu` - **Макс. качество (Intel GPU):** `large-v3` + `--device openvino-gpu`
- **Макс. скорость CPU (русский):** `--device onnx --model gigaam-v3` (17-29× RTF, без пунктуации; рекомендуется LLM-нормализация терминов после)
- **CPU с пунктуацией (русский):** `--device openvino-cpu --model medium` (5-6× RTF; для встреч ≤30 мин с равномерной громкостью — на длинных файлах с тихими фрагментами возможны галлюцинации)
- **Быстрый тест:** `tiny` — для проверки пайплайна - **Быстрый тест:** `tiny` — для проверки пайплайна
<details> <details>
@@ -235,6 +270,17 @@ language = "en"
| `medium` | ~1.5 GB | ~2.5 GB | ★★ | ★★★★ | | `medium` | ~1.5 GB | ~2.5 GB | ★★ | ★★★★ |
| `large-v3` | ~3 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>
<details> <details>
+135
View File
@@ -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-редукция, иноязычные вставки) делают его непригодным для русского; для других языков не валидировано в этом эксперименте |
+170
View File
@@ -0,0 +1,170 @@
# Backlog — будущие эксперименты и направления
Список открытых направлений, которые имеют смысл, но не реализованы. Каждый пункт содержит обоснование и ссылку на источник (ADR / статья), чтобы при возврате не пришлось воспроизводить контекст с нуля.
Когда направление становится в работу — переносится в spec/план или соответствующий ADR. Когда отклоняется — остаётся в backlog с пометкой «отклонено» и причиной (для истории решений).
---
## ASR-бэкенды и модели
### GigaAM v3 RNN-T — потенциальная замена CTC-дефолта для `--device onnx`
**Что:** Прогнать `gigaam-v3-rnnt` (или `v3_e2e_rnnt` если доступен через onnx-asr) по той же методологии, что и в [ADR-006](adr/006-onnx-asr-backend.md): 3 файла × agent-judge × 4 критерия.
**Почему интересно:**
- **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) — оценка, нужна верификация.
**Если подтвердится бенчмарком:**
- Дефолт в `--device onnx` меняется с `gigaam-v3-ctc` на `gigaam-v3-rnnt`.
- `openvino-cpu medium` уходит из рекомендаций для русского.
- Формулировка «GPU faster-whisper large-v3 — эталон» в README может стать неточной.
- Пишется ADR-007 с переоценкой дефолта.
**Уточнить перед запуском:** какой именно вариант RNN-T доступен в onnx-asr (`gigaam-v3-rnnt` без пунктуации vs `v3_e2e_rnnt` с пунктуацией). Проверить через `huggingface_hub` listing для `istupakov/gigaam-v3-onnx`.
---
### 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 галлюцинации на длинных файлах с тихими фрагментами
**Что:** Воспроизвести и зафиксировать класс ошибок: `12-02-37` теряет 17 минут хвоста, `Vasya` имеет 5-минутные блоки повторов, появляются несуществующие имена (`Валерий Сюткин`). Источник: agent-judge в [ADR-006](adr/006-onnx-asr-backend.md#класс-ошибок-whisper-medium--галлюцинации-на-длинных-файлах-с-тихими-фрагментами).
**Возможные направления:**
- Ограничить длину чанка для openvino-medium (chunk_length параметр в WhisperPipeline).
- Внедрить `compression_ratio_threshold` / `log_prob_threshold` фильтры через переписывание pipeline (как у CTranslate2). Уже частично описано в [docs/gpu.md «Качественный pipeline для OpenVINO»](gpu.md#качественный-pipeline-для-openvino).
- Предупреждать пользователя при `--device openvino-cpu` для файлов >30 мин.
**Приоритет:** средний — пока есть `gigaam-v3` как альтернатива для русского. Критично, если openvino остаётся единственным вариантом для пунктуации (отпадает после теста RNN-T).
---
### Интеллектуальное чанкование длинных файлов по паузам
**Что:** Резать длинные файлы на чанки (~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
### Включение `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#решение).
---
## Отклонённые направления
*(пока пусто — добавлять сюда то, что попробовали и решили не делать, с причиной)*
+1
View File
@@ -12,6 +12,7 @@ dependencies = [
"nvidia-cublas-cu12>=12.4; sys_platform == 'linux' and platform_machine == 'x86_64'", "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')", "openvino-genai>=2025.0; sys_platform != 'darwin' and (platform_machine == 'x86_64' or platform_machine == 'AMD64')",
"tomli>=2.0; python_version < '3.11'", "tomli>=2.0; python_version < '3.11'",
"onnx-asr[cpu,hub]>=0.11.0,<0.12.0",
] ]
[project.scripts] [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 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 # cuda, cpu и всё остальное → faster-whisper
from .faster_whisper import FasterWhisperBackend from .faster_whisper import FasterWhisperBackend
+154
View File
@@ -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)
+109 -3
View File
@@ -9,9 +9,23 @@ from rich.console import Console
from rich.status import Status from rich.status import Status
from .config import apply_device_defaults, load_config, resolve_defaults 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 ( from .transcriber import (
Segment, Segment,
TranscribeResult,
_is_cuda_error, _is_cuda_error,
_transcribe_file, _transcribe_file,
load_model, load_model,
@@ -43,9 +57,62 @@ def _format_device_info(device_used: str) -> str:
return "CPU" 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() @app.command()
def main( def main(
files: list[Path] = typer.Argument(..., help="Пути к аудио/видеофайлам"), files: list[Path] | None = typer.Argument(None, help="Пути к аудио/видеофайлам"),
model: str | None = typer.Option( model: str | None = typer.Option(
None, "--model", "-m", show_default=False, help="Модель Whisper [по умолч.: medium]" None, "--model", "-m", show_default=False, help="Модель Whisper [по умолч.: medium]"
), ),
@@ -55,7 +122,7 @@ def main(
output: Path | None = typer.Option(None, "--output", "-o", help="Путь к выходному файлу"), output: Path | None = typer.Option(None, "--output", "-o", help="Путь к выходному файлу"),
device: str | None = typer.Option( device: str | None = typer.Option(
None, "--device", "-d", show_default=False, 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( compute_type: str | None = typer.Option(
None, "--compute-type", show_default=False, None, "--compute-type", show_default=False,
@@ -67,11 +134,48 @@ def main(
), ),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Подробный вывод"), verbose: bool = typer.Option(False, "--verbose", "-v", help="Подробный вывод"),
force: bool = typer.Option(False, "--force", "-f", 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: ) -> None:
"""Транскрибирует аудио/видеофайлы в markdown с таймкодами. """Транскрибирует аудио/видеофайлы в markdown с таймкодами.
Каскад приоритетов параметров: CLI-флаги > .transcriber.toml > device-aware дефолты. Каскад приоритетов параметров: 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: try:
config = load_config() config = load_config()
cli_values = {"model": model, "language": language, "device": device, "compute_type": compute_type} cli_values = {"model": model, "language": language, "device": device, "compute_type": compute_type}
@@ -218,6 +322,7 @@ def _run_single(
elapsed = time.monotonic() - start elapsed = time.monotonic() - start
console.print(f"Транскрипт сохранён: \"{output_path}\"", style="green") console.print(f"Транскрипт сохранён: \"{output_path}\"", style="green")
console.print(f" Сегментов: {len(result.segments)} Время: {elapsed:.1f}с") console.print(f" Сегментов: {len(result.segments)} Время: {elapsed:.1f}с")
_print_quality_warnings(result)
def _run_batch( def _run_batch(
@@ -353,6 +458,7 @@ def _run_batch(
style="green", style="green",
) )
processed += 1 processed += 1
_print_quality_warnings(result, file.name)
except KeyboardInterrupt: except KeyboardInterrupt:
raise raise
except Exception as exc: except Exception as exc:
+2 -1
View File
@@ -22,11 +22,12 @@ DEVICE_DEFAULTS: dict[str, dict[str, str]] = {
"openvino": {"model": "medium", "compute_type": "int8"}, "openvino": {"model": "medium", "compute_type": "int8"},
"openvino-gpu": {"model": "medium", "compute_type": "int8"}, "openvino-gpu": {"model": "medium", "compute_type": "int8"},
"openvino-cpu": {"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_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: def find_config_file() -> Path | None:
+57
View File
@@ -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
+16 -2
View File
@@ -4,6 +4,7 @@ from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from .quality import TAIL_GAP_WARN_S, find_repetition_blocks, tail_gap
from .types import Segment, TranscribeResult from .types import Segment, TranscribeResult
_PAUSE_THRESHOLD_S = 2.0 # пауза между сегментами для разбиения на абзацы _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}" return f"{minutes:02d}:{secs:02d}.{centiseconds:02d}"
def _format_duration(seconds: float) -> str: def format_duration(seconds: float) -> str:
"""Человекочитаемая длительность для метаданных в шапке транскрипта.""" """Человекочитаемая длительность для метаданных в шапке транскрипта."""
total = int(seconds) total = int(seconds)
h = total // 3600 h = total // 3600
@@ -92,7 +93,20 @@ def format_transcript(
lines.append(f"- **Дата транскрипции**: {date.strftime('%Y-%m-%d %H:%M:%S')}") lines.append(f"- **Дата транскрипции**: {date.strftime('%Y-%m-%d %H:%M:%S')}")
lines.append(f"- **Модель**: {model_name}") lines.append(f"- **Модель**: {model_name}")
lines.append(f"- **Язык**: {result.language} ({language_mode})") 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(f"- **Устройство**: {device_info}")
lines.append("") lines.append("")
lines.append("---") lines.append("---")
+78
View File
@@ -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
+247
View File
@@ -2,6 +2,7 @@ from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
from rich.console import Console
from typer.testing import CliRunner from typer.testing import CliRunner
from local_transcriber.cli import _format_device_info, app from local_transcriber.cli import _format_device_info, app
@@ -921,3 +922,249 @@ def test_cli_threads_negative_rejected(tmp_path):
audio.write_bytes(b"fake") audio.write_bytes(b"fake")
out = runner.invoke(app, [str(audio), "--threads", "-1"]) out = runner.invoke(app, [str(audio), "--threads", "-1"])
assert out.exit_code != 0 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
+128
View File
@@ -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()
+138
View File
@@ -172,3 +172,141 @@ def test_write_transcript(tmp_path):
out = tmp_path / "output.md" out = tmp_path / "output.md"
write_transcript("# Test content\n", out) write_transcript("# Test content\n", out)
assert out.read_text(encoding="utf-8") == "# Test content\n" 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
+314
View File
@@ -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")
+125
View File
@@ -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, "Повтор")]) == []
Generated
+24
View File
@@ -301,6 +301,7 @@ source = { editable = "." }
dependencies = [ dependencies = [
{ name = "faster-whisper" }, { name = "faster-whisper" },
{ name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { 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 = "openvino-genai", marker = "(platform_machine == 'AMD64' and sys_platform != 'darwin') or (platform_machine == 'x86_64' and sys_platform != 'darwin')" },
{ name = "rich" }, { name = "rich" },
{ name = "socksio" }, { name = "socksio" },
@@ -317,6 +318,7 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "faster-whisper", specifier = ">=1.2.1" }, { name = "faster-whisper", specifier = ">=1.2.1" },
{ name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=12.4" }, { 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 = "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 = "rich" },
{ name = "socksio", specifier = ">=1.0.0" }, { 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" }, { 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]] [[package]]
name = "onnxruntime" name = "onnxruntime"
version = "1.24.3" version = "1.24.3"