Author SHA1 Message Date
Dmitriy DementievandClaude Opus 5 80b864077a docs(specs): спека onnx-каталога, Python 3.13 и границ версий
- Зачем:
  - смешанная русско-английская речь и отсутствие пунктуации у gigaam-v3-ctc
    закрываются моделями из onnx-asr 0.12, но обновление заблокировано пином.
- Что:
  - добавлена спека: три модели в каталог, Python 3.13 и ORT 1.28, верхние
    границы мажорных версий, ловушки и границы ручной приёмки.
  - в backlog добавлены ссылка на спеку из пункта про CPU-дефолт и новое
    направление «профили намерения вместо выбора модели».
- Проверка:
  - задача трекера #1 с порядком работ и критериями приёмки.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 11:39:28 +03:00
Dmitriy DementievandClaude Opus 5 579cb87bc0 docs(context): убраны термины процедуры сравнительной оценки
- Зачем:
  - формальной процедуры оценки моделей не будет: проект домашний, CI нет,
    репрезентативные записи приватны, а тяжёлый пайплайн повышает шанс
    забросить работу.
- Что:
  - удалены «Кандидатная модель», «Сравнительная оценка», «Контрольная
    модель», «Интеграционный smoke-тест» и «Локальный оценочный корпус».
  - оставлены «Движок распознавания», «Поддерживаемая модель» и «Модель по
    умолчанию» — они держат различие между поддержкой и рекомендацией.
- Проверка:
  - git diff HEAD~1 -- CONTEXT.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 11:37:30 +03:00
Dmitriy Dementiev b302e3dbb2 docs(agents): добавлена конфигурация инженерных навыков
- Зачем:
  - инженерным навыкам нужна единая конфигурация трекера задач и доменных документов.
- Что:
  - задокументированы Gitea workflow через tea и стандартные triage-метки.
  - добавлены доменный словарь и правила работы с ADR, research и specs.
- Проверка:
  - git diff --cached --check.
  - tea --version: 0.15.1.
2026-08-11 11:34:18 +03:00
Dmitriy Dementiev f8c6a4fc7e docs(asr): зафиксировано исследование обновлений и CPU-дефолта
- Зачем:
  - нужен проверяемый путь от обновления ASR-стека к выбору безопасного CPU-дефолта.
- Что:
  - добавлено исследование актуальных движков и моделей с корректным именем файла.
  - зафиксированы дефект OpenVINO, кандидаты GigaAM и матрица бенчмарка на Intel Core i5.
- Проверка:
  - выполнена проверка git diff --cached --check.
2026-08-10 19:07:59 +03:00
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
ddadmin 0b5703a0e3 docs(adr): ADR-005 — Parakeet TDT оценён и отклонён для русских ИТ-встреч
- Зачем:
  - зафиксировать в master результат эксперимента с Parakeet-бэкендом:
    что проверяли, какие цифры получили, почему оставили Whisper как дефолт.
    Знания доступны, код — в экспериментальной ветке feature/parakeet-backend.
- Что:
  - docs/adr/005-parakeet-evaluation.md — полный контекст решения: гипотеза,
    методология, метрики скорости (Parakeet 2.7x быстрее, -35% RAM),
    качество (Summary utility 2-3/5 vs 4/5 у Whisper), систематические
    проблемы модели на тихой русской речи, VAD-тюнинг не помог, условия
    пересмотра решения в будущем (английский / live-captioning / русские
    specialized-модели через onnx-asr).
  - docs/gpu.md — короткая заметка под «Потенциальные направления развития»
    со ссылкой на ADR-005.
- Проверка:
  - cat docs/adr/005-parakeet-evaluation.md.
  - grep -A5 "Parakeet TDT — проверено" docs/gpu.md.
  - ветка feature/parakeet-backend сохранена и не удаляется.
2026-04-19 01:26:20 +03:00
ddadmin 2c1d798eeb Revert "docs(specs): дизайн-спек Parakeet backend"
This reverts commit cb6c1d7728.
2026-04-18 20:32:48 +03:00
ddadmin cb6c1d7728 docs(specs): дизайн-спек Parakeet backend
- Зачем:
  - зафиксировать дизайн экспериментального бэкенда до имплементации; гипотеза — Parakeet TDT v3 быстрее/качественнее Whisper на Intel/AMD встройках.
- Что:
  - добавлен docs/superpowers/specs/2026-04-18-parakeet-backend-design.md после 3 раундов adversarial-review.
  - зафиксированы: onnx-asr+Silero VAD как runtime, CPU EP в MVP, --device parakeet, int8/fp32 compute_type, no-fallback политика, long-audio gate с blocker-thresholds (peak RSS ≤4GB, первый status ≤10с, interval ≤30с, offline после первого запуска).
- Проверка:
  - cat docs/superpowers/specs/2026-04-18-parakeet-backend-design.md.
2026-04-18 20:26:56 +03:00
ddadminandClaude Opus 4.6 749ede090c docs(gpu): Quadro M3000M (CC 5.0) — CUDA не работает в CTranslate2 4.x
float16 требует CC ≥ 7.0, float32 падает с ошибкой — пребилды
CTranslate2 4.x не включают sm_50/sm_60. Рекомендация: --device cpu
или --device openvino.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:56:51 +03:00
ddadminandClaude Opus 4.6 59b74f166c docs(gpu): уточнить модель CPU — Intel i7-11800H
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:52:17 +03:00
ddadminandClaude Opus 4.6 857267763c feat(cli): --threads для управления CPU-потоками + бенчмарк CUDA compute_type
- Зачем:
  - CTranslate2 по умолчанию использует 4 потока; на многоядерных CPU (8+ ядер)
    это неоптимально — --threads 8 даёт +13% ускорения.
  - Не было данных по int8_float32/int8_float16 на NVIDIA GPU.

- Что:
  - --threads / -t: новый CLI-флаг, пробрасывается через load_model →
    backend.create_model(cpu_threads=...) → WhisperModel(cpu_threads=...).
  - Валидация min=0 на входе (typer), Backend протокол синхронизирован.
  - docs/gpu.md: результаты бенчмарка 6 комбинаций CUDA compute_type
    (medium/large-v3 × float16/int8_float32/int8_float16) на двух файлах
    (16 мин и 46 мин). Ключевой вывод: float16 — оптимальный дефолт;
    large-v3 ненадёжен на длинных записях.
  - README: --threads добавлен в таблицу опций.
  - Фикс теста: test_resolve_repo_explicit_unsupported_pair_raises обновлён
    под добавление medium fp16 модели.

- Проверка:
  - uv run pytest: 157 passed.
  - transcribe file.mp4 --device cpu --threads 8: 277с vs 320с (дефолт).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:49:16 +03:00
Dmitriy DementievandClaude Opus 4.6 8ae74d2748 docs(gpu): потенциальные направления — quality pipeline для OpenVINO, CUDA int8_float32
Зафиксированы два направления развития:
- temperature fallback и фильтры галлюцинаций для OpenVINO бэкенда
- тестирование int8_float32/int8_float16 на CUDA

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:00:31 +03:00
Dmitriy DementievandClaude Opus 4.6 248f260005 docs(gpu): CPU int8_float32, сравнение pipeline-ов, CUDA рекомендации
- Добавлена секция CPU бэкенда: int8_float32 как рекомендация, тюнинг потоков
- Объяснение разницы качества OpenVINO vs CTranslate2 (pipeline декодирования)
- Секция CUDA: int8_float32/int8_float16 как альтернатива для тестирования
- Обновлена таблица OpenVINO моделей (medium fp16)
- Обновлены таблицы скорости и качества

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:45:17 +03:00
Dmitriy DementievandClaude Opus 4.6 413cb3e6d8 feat(openvino): добавить medium fp16 модель как опцию
OpenVINO/whisper-medium-fp16-ov доступна через --compute-type fp16.
На CPU fp16 эмулируется программно (медленно), но на Intel Arc GPU
даёт аппаратное ускорение через XMX. Не меняет дефолты.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:48:29 +03:00
Dmitriy DementievandClaude Opus 4.6 9d41b893c3 docs(readme): добавить int8_float32 в таблицу compute_type
Бенчмарк показал: int8_float32 даёт 1.5x ускорение на CPU при сохранении
качества float32. Добавлен как рекомендуемый вариант для CPU.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:04:24 +03:00
34 changed files with 2746 additions and 44 deletions
+3
View File
@@ -4,3 +4,6 @@ __pycache__/
.mypy_cache/
dist/
*.pyc
.codex
.qwen/
.scratch/
+90
View File
@@ -0,0 +1,90 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
Local audio/video transcription CLI — no cloud, no API keys. Outputs markdown with timestamps.
**Language conventions**: code identifiers in English; docstrings, comments, UI strings, and commit messages in Russian. Style is ruff-compatible. Commits follow [Conventional Commits](https://www.conventionalcommits.org/).
## Commands
```bash
uv sync # install dependencies
uv run transcribe meeting.mp4 # run CLI
uv run pytest # run all tests
uv run pytest tests/test_cli.py # run one test file
uv run pytest -k test_name # run single test by name
uv run pytest -v # verbose output
```
Package manager is **uv** (not pip). Build backend is hatchling.
## Architecture
```
CLI (cli.py)
→ config.py cascade: CLI arg → .transcriber.toml → device-aware default → hardcoded
→ utils.py detect_device(), validate files, expand globs (Windows workaround)
→ context_menu.py Windows SendTo: Transcribe.cmd install/uninstall (--install-menu / --uninstall-menu)
→ transcriber.py load_model() → get_backend(device) → ensure_model_available → create_model
_transcribe_file() with mid-stream CUDA→CPU fallback
→ formatter.py segments → markdown with timestamps, paragraph grouping (>2s pause or >60s)
```
### Backend system (`src/local_transcriber/backends/`)
Three backends implement the `Backend` Protocol (structural typing, no inheritance required):
| Backend | Module | Devices | Library |
|---------|--------|---------|---------|
| FasterWhisper | `faster_whisper.py` | `cpu`, `cuda` | `faster_whisper` (CTranslate2) |
| OpenVINO | `openvino.py` | `openvino`, `openvino-gpu`, `openvino-cpu` | `openvino_genai` |
| OnnxAsr | `onnx_asr.py` | `onnx` | `onnx_asr` (onnxruntime) |
`get_backend(device)` in `backends/__init__.py` maps device string to backend with lazy imports.
### Key design decisions
- **Two-level fallback**: GPU→CPU at model load time AND mid-stream during transcription (GPU visible via nvidia-smi but insufficient VRAM).
- **CUDA bootstrap** (`_cuda_bootstrap.py`): preloads `libcublas.so.12` via `ctypes.CDLL(RTLD_GLOBAL)` before importing ctranslate2, because pip's `nvidia-cublas-cu12` installs to a non-standard path and `LD_LIBRARY_PATH` can't be changed at runtime (glibc caches it).
- **Batch mode**: 3-phase pipeline (prescan → load model once → transcribe all). `TranscribeFileResult` carries updated model/backend/device state between files.
- **Device-aware defaults**: `compute_type` and `model` vary by device (float16 for CUDA, int8 for OpenVINO, float32 for CPU). Defined in `config.py` `DEVICE_DEFAULTS`.
- **OpenVINO uses pre-quantized models** — `compute_type` selects which HF repo to download, not a runtime parameter.
## Testing
All tests mock backends — no real model downloads or transcription. Key test patterns:
- CLI tests: `typer.testing.CliRunner` + mocks for `load_config`, `detect_device`, `load_model`, `_transcribe_file`, `write_transcript`
- `_single_patches()` — helper assembling standard happy-path mock set
- `_make_result()` / `_make_tfr()` — factories for test data
## Common tasks
- **New CLI option**: add `typer.Option` in `cli.py:main()` → add key to `HARDCODED_DEFAULTS` in `config.py` → write test
- **New audio/video format**: add extension to `SUPPORTED_EXTENSIONS` in `utils.py`
- **New backend**: implement `Backend` protocol → add device mapping in `backends/__init__.py` → add device-aware defaults in `config.py`
- **Change output format**: edit `format_transcript()` in `formatter.py`
## Project docs
- `docs/PRD.md` — product requirements and scope
- `docs/backlog.md` — future experiments and ideas
- `docs/gpu.md` — GPU benchmarks, platform compatibility details
- `docs/adr/` — architecture decision records (CUDA bootstrap, batch mode, pluggable backends, compute-type defaults, ONNX-ASR evaluation)
## Agent skills
### Issue tracker
Задачи ведутся в Gitea через `tea`; GitHub используется только как зеркало, внешние PR не входят в triage. См. `docs/agents/issue-tracker.md`.
### Triage labels
Используются стандартные пять triage-меток. См. `docs/agents/triage-labels.md`.
### Domain docs
Репозиторий использует single-context layout. См. `docs/agents/domain.md`.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+17
View File
@@ -0,0 +1,17 @@
# Локальная транскрипция
Контекст описывает язык проекта для локального распознавания аудио и видео.
## Language
**Движок распознавания**:
Программная среда, которая загружает и исполняет модели распознавания. Обновление движка само по себе не означает изменение выбранной модели или рекомендаций пользователю.
_Avoid_: Модель, ASR-модель
**Поддерживаемая модель**:
Модель, которую пользователь может выбрать явно и для которой проект обеспечивает работоспособный путь транскрипции. Этот статус не означает автоматический выбор или рекомендацию для большинства пользователей.
_Avoid_: Доступная модель, дефолт
**Модель по умолчанию**:
Поддерживаемая модель, которую проект выбирает без явного указания модели пользователем для определённого пути выполнения.
_Avoid_: Рекомендуемая модель, поддерживаемая модель
+56 -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 с таймкодами** — удобен для суммаризации ИИ
- **Аудио и видео** — mp3, wav, mp4, mkv и [другие форматы](#поддерживаемые-форматы)
@@ -114,6 +115,12 @@ transcribe podcast.wav --model large-v3 --compute-type float16
# Максимальное качество на Intel GPU
transcribe podcast.wav --model large-v3 --device openvino-gpu
# Максимальная скорость на CPU (русский)
transcribe meeting.mp4 --device onnx --model gigaam-v3
# CPU с пунктуацией (русский, для parakeet-v3 нужен явный язык)
transcribe podcast.wav --device onnx --model parakeet-v3 --language ru
# Сохранить в конкретный файл
transcribe interview.m4a --output result.md
```
@@ -138,6 +145,31 @@ transcribe *.mp4 --force
- При ошибке в одном файле остальные продолжают обрабатываться
- `--output` несовместим с несколькими файлами
### Контекстное меню проводника (Windows)
Установить пункт `Transcribe` в меню «Отправить»:
```bash
transcribe --install-menu
```
(при запуске из клона репозитория — `uv run transcribe --install-menu`)
Использование: выделите один или несколько аудио/видеофайлов в проводнике, откройте контекстное меню правой кнопкой. В Windows 11 выберите «Показать дополнительные параметры» или нажмите Shift+F10, затем «Отправить» → «Transcribe». Несколько выделенных файлов передаются в один процесс и обрабатываются одним батчем.
Удалить пункт меню:
```bash
transcribe --uninstall-menu
```
Если что-то пошло не так, пункт можно удалить вручную: Win+R → `shell:sendto` → удалить `Transcribe.cmd`.
Известные ограничения:
- После переноса или пересоздания проекта/venv выполните `--install-menu` заново: внутри `Transcribe.cmd` хранится абсолютный путь к `transcribe.exe`.
- Очень большой мультивыбор с суммарной длиной путей ≳8000 символов упирается в лимит командной строки cmd.exe. Обрабатывайте такие файлы частями.
### Опции CLI
| Опция | Сокращение | По умолчанию | Описание |
@@ -145,8 +177,9 @@ transcribe *.mp4 --force
| `--model` | `-m` | `medium` | Модель Whisper |
| `--language` | `-l` | `ru` | Язык (ru, en, auto и др.) |
| `--output` | `-o` | `<файл>-transcript.md` | Путь к выходному файлу |
| `--device` | `-d` | `auto` | Устройство (auto, cpu, cuda, openvino, openvino-gpu, openvino-cpu) |
| `--compute-type` | — | float16 (CUDA) / int8 (OpenVINO GPU/CPU) / float32 (CPU) | Тип вычислений |
| `--device` | `-d` | `auto` | Устройство (auto, cpu, cuda, openvino, openvino-gpu, openvino-cpu, onnx) |
| `--compute-type` | — | float16 (CUDA) / int8 (OpenVINO/ONNX) / float32 (CPU) | Тип вычислений |
| `--threads` | `-t` | 0 (авто) | Потоки CPU (рекомендуется = число физ. ядер) |
| `--force` | `-f` | — | Перезаписать существующие транскрипты |
| `--verbose` | `-v` | — | Подробный вывод |
@@ -157,6 +190,7 @@ transcribe *.mp4 --force
| CPU | ✅ | ✅ | ✅ |
| OpenVINO (x86 CPU) | ✅ авто | — | ✅ авто |
| OpenVINO (Intel GPU) | ✅ авто | — | ✅ авто |
| ONNX (CPU) | ✅ явно | ✅ явно | ✅ явно |
| GPU (NVIDIA) | ✅ авто | — | ✅ (нужен CUDA 12) |
<details>
@@ -209,11 +243,11 @@ language = "en"
Дефолты зависят от устройства:
| Параметр | CUDA | OpenVINO (GPU) | OpenVINO (CPU) | CPU |
|----------|------|----------------|----------------|-----|
| model | medium | medium | medium | medium |
| compute_type | float16 | int8 | int8 | float32 |
| language | ru | ru | ru | ru |
| Параметр | CUDA | OpenVINO (GPU) | OpenVINO (CPU) | ONNX | CPU |
|----------|------|----------------|----------------|------|-----|
| model | medium | medium | medium | gigaam-v3 | medium |
| compute_type | float16 | int8 | int8 | int8 | float32 |
| language | ru | ru | ru | ru | ru |
## Модели и GPU
@@ -221,6 +255,8 @@ language = "en"
- **По умолчанию:** `medium` — хороший баланс скорости и качества
- **Макс. качество (NVIDIA):** `large-v3` + `--compute-type float16`
- **Макс. качество (Intel GPU):** `large-v3` + `--device openvino-gpu`
- **Макс. скорость CPU (русский):** `--device onnx --model gigaam-v3` (17-29× RTF, без пунктуации; рекомендуется LLM-нормализация терминов после)
- **CPU с пунктуацией (русский):** `--device openvino-cpu --model medium` (5-6× RTF; для встреч ≤30 мин с равномерной громкостью — на длинных файлах с тихими фрагментами возможны галлюцинации)
- **Быстрый тест:** `tiny` — для проверки пайплайна
<details>
@@ -234,6 +270,17 @@ language = "en"
| `medium` | ~1.5 GB | ~2.5 GB | ★★ | ★★★★ |
| `large-v3` | ~3 GB | ~2.5 GB | ★ | ★★★★★ |
#### ONNX-модели (`--device onnx`)
Другие архитектуры, не Whisper. Работают через onnxruntime на CPU:
| Модель | Размер (int8) | RTFx CPU | Языки | Пунктуация |
|--------|--------------|----------|-------|-----------|
| `gigaam-v3` | ~300 MB | 17-29× | ru | ❌ |
| `parakeet-v3` | ~600 MB | 12-20× | 25 языков | ✅ |
> **Рекомендация**: для русского — `gigaam-v3` (единственный из onnx-моделей, дающий пригодный для конспекта транскрипт на русских встречах; см. [ADR-006](docs/adr/006-onnx-asr-backend.md)). `parakeet-v3` уместен только для англоязычного / multilingual контента — на русском воспроизводит проблемы из [ADR-005](docs/adr/005-parakeet-evaluation.md) (Mm-hmm-редукция тихих реплик, иноязычные вставки).
</details>
<details>
@@ -243,6 +290,7 @@ language = "en"
|-----|--------|----------|----------|--------------------|
| `float16` | CUDA | ~4.5-5 GB | Отлично | **По умолчанию для CUDA** |
| `int8_float16` | CUDA | ~4.7 GB | Отлично | GPU от 6 GB, альтернатива float16 |
| `int8_float32` | CPU | Среднее | Отлично | **Рекомендуется для CPU** — 1.5x быстрее float32 при том же качестве |
| `int8` | CUDA / OpenVINO | Низкое | Хорошо, но бывают галлюцинации | **По умолчанию для OpenVINO** |
| `fp16` | OpenVINO | Низкое | Отлично | OpenVINO large-v3 (выбирается автоматически) |
| `float32` | CPU | Среднее | Отлично | **По умолчанию для CPU** |
+102
View File
@@ -0,0 +1,102 @@
# ADR-005: NVIDIA Parakeet TDT — оценено, отклонено для дефолтного use case
**Статус**: Отклонено (оставлено в экспериментальной ветке)
**Дата**: 2026-04-19
## Контекст
ADR-003 и ADR-004 зафиксировали стратегию: CTranslate2 на CPU (качество) +
OpenVINO (скорость на x86). Оба бэкенда — Whisper. Возникла гипотеза, что
**NVIDIA Parakeet TDT 0.6B v3** может выйти за пределы этого trade-off: модель
заявлена multilingual (25 языков включая русский), имеет ONNX-порт
[istupakov/parakeet-tdt-0.6b-v3-onnx](https://huggingface.co/istupakov/parakeet-tdt-0.6b-v3-onnx),
работает через `onnx-asr` + Silero VAD. Есть позитивные отзывы (напр.
приложение Handy использует Parakeet для dictation в реальном времени).
Основное применение проекта — транскрипция русскоязычных ИТ-встреч
(ментор ↔ менти, ~15–60 мин, ноутбучный микрофон). Если Parakeet обойдёт
Whisper medium int8 по скорости при сравнимом качестве, или по качеству при
сравнимой скорости — это основание поменять дефолт.
## Эксперимент
Реализован полный бэкенд в ветке `feature/parakeet-backend` (ONNX Runtime CPU EP,
Silero VAD, cache-first model loading). Прогнан автоматический бенчмарк
на двух 15-минутных отрывках реальных установочных встреч
(`scripts/quality_bench.py` в ветке), CPU Intel i7-11800H.
Качество оценивал агент-судья по шкале 1–5 по критериям completeness /
term accuracy / fluency / summary utility — на пригодность транскрипта
для построения конспекта без возврата к аудио.
### Скорость и ресурсы
| Отрывок | Бэкенд | Wall | RTFx | Peak RSS |
|---|---|---|---|---|
| artur-intro (15 мин) | **parakeet-int8** | 129.5s | **6.92x** | 2.21 GB |
| artur-intro (15 мин) | openvino-medium-int8 | 354.2s | 2.54x | 3.50 GB |
| mar15-mid (15 мин) | **parakeet-int8** | 159.6s | **5.64x** | 2.65 GB |
| mar15-mid (15 мин) | openvino-medium-int8 | 428.4s | 2.10x | 3.52 GB |
**Parakeet в 2.7x быстрее и использует на ~35% меньше RAM.**
### Качество (Summary utility, 15)
| Отрывок | Parakeet int8 | Whisper medium int8 |
|---|---|---|
| artur-intro | **2** | **4** |
| mar15-mid | **3** | **4** |
Систематические проблемы Parakeet на русской речи с низкой громкостью:
1. **Редукция коротких реплик менти до `Mm-hmm`/`Yeah`** — теряется
значительная часть диалога (низкий уровень микрофона менти).
2. **Вставки кусков польского/испанского/немецкого** посреди русской речи
(`Miejsce w sześć zajmie`, `Que salviosa`, `Genial positions`).
3. **Сильное искажение ИТ-терминов и бытовых выражений**
`Pretty subs` вместо «пройти собес», `Состем основ` вместо «система контроля
версий», `І на глуп Джой` вместо `inner loop join`.
Whisper medium int8 даёт связный русский текст с нормальной пунктуацией;
искажения присутствуют (`капка` вместо Kafka, одна галлюцинация
«Добро пожаловать в наш канал!» на паузе), но не критичны для конспекта.
### Попытка тюнинга — Silero VAD threshold
Гипотеза: дефолтный `threshold=0.5` обрезает тихие реплики менти. Проверено
на 0.5/0.3/0.2 — снижение порога **уменьшает** объём транскрипта
(Parakeet хуже декодирует длинные склеенные сегменты с разреженной речью)
и **не снижает** частоту `Mm-hmm`-редукций. Проблема inherent для модели
на тихом русском диалоге, не артефакт VAD.
## Решение
**Parakeet TDT 0.6B v3 не принимается как дефолтный или опциональный бэкенд
для целевого use case.** Whisper medium int8 остаётся предпочтительным —
несмотря на 2.7x проигрыш по скорости, он даёт транскрипт, из которого можно
сделать конспект без возврата к аудио. Для русской речи с варьирующейся
громкостью микрофона Parakeet v3 недостаточно устойчив.
Код бэкенда **не мержится в master**. Экспериментальная ветка
`feature/parakeet-backend` сохраняется как reference для будущих экспериментов.
## Последствия
- Архитектура pluggable backends (ADR-003) подтвердила ценность: интегрировать
и оценить новый бэкенд оказалось недорого.
- Surface area master не растёт: нет `--device parakeet-cpu`, нет зависимости
`onnx-asr`, нет новой ветки в CLI.
- Если в будущем появится интерес — ветка `feature/parakeet-backend` содержит
готовый бэкенд (13 задач, ~19 тестов), скрипты бенчмарка и VAD-тюнинга.
## Когда стоит пересмотреть решение
1. **Англоязычный контент** — Parakeet обучался преимущественно на английском,
для native English встреч выигрыш в скорости может окупить качество.
2. **Live-captioning / dictation** (как в Handy) — другой use case, близкий
микрофон + короткие фразы, проблемы редукции неактуальны.
3. **Русские specialized-модели через ту же обвязку** `onnx-asr`:
`gigaam-v3-rnnt` (SberDevices), `nemo-fastconformer-ru-rnnt` (NVIDIA),
`t-one-ctc` (T-Bank). Это отдельная ветка экспериментов, не Parakeet.
4. **Слабые машины с ограниченной RAM** — Parakeet 2.2 GB vs Whisper 3.5 GB
на 15-мин аудио. Если важна RAM-экономия и английский контент.
+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-редукция, иноязычные вставки) делают его непригодным для русского; для других языков не валидировано в этом эксперименте |
+47
View File
@@ -0,0 +1,47 @@
# Domain Docs
Репозиторий использует single-context layout.
## Перед исследованием кода
- Прочитать корневой `CONTEXT.md`.
- Прочитать относящиеся к задаче решения из `docs/adr/`.
- Проверить относящиеся к задаче исследования в `docs/research/`.
- Проверить относящиеся к задаче спецификации в `docs/specs/`.
- Если документа нет, продолжить молча: доменные документы создаются лениво
соответствующими навыками.
## Структура
```text
/
├── CONTEXT.md
├── docs/
│ ├── adr/
│ ├── research/
│ │ └── YYYY-MM-DD-slug.md
│ └── specs/
│ └── YYYY-MM-DD-slug.md
└── src/
```
## Назначение документов
- `CONTEXT.md` — каноническая терминология предметной области.
- `docs/adr/` — принятые архитектурные решения и их обоснование.
- `docs/research/YYYY-MM-DD-slug.md` — результаты исследований, основанные на
источниках и экспериментах.
- `docs/specs/YYYY-MM-DD-slug.md` — согласованные спецификации изменений.
## Терминология
В задачах, тестах, предложениях и документации использовать термины из
`CONTEXT.md`. Не заменять их синонимами, перечисленными в `_Avoid_`.
Если нужного понятия нет, проверить, действительно ли это доменный термин.
Существенный пробел передать в `domain-modeling`.
## Конфликты с ADR
Если предлагаемое изменение противоречит существующему ADR, указать конфликт
явно и объяснить, почему решение стоит пересмотреть.
+66
View File
@@ -0,0 +1,66 @@
# Issue Tracker
Задачи проекта ведутся в Gitea-репозитории `ddmitry/local-transcriber`.
- Основной remote: `origin`
- Gitea: `https://git.dementev.space`
- CLI: `tea`
- Remote `github` является зеркалом и не используется для управления задачами
- Внешние pull request не входят в очередь triage
## Доступ
Перед операциями с задачами проверить наличие `tea`.
Если команда недоступна, остановиться и предложить пользователю установку:
```powershell
winget install --id Gitea.tea --exact
```
Не переключаться автоматически на GitHub Issues или локальные markdown-задачи.
Проверить настроенные подключения:
```powershell
tea login list
```
Если подходящего подключения нет, предложить пользователю настроить его через
`tea login add`. Не запрашивать и не выводить токены в переписке или логах.
## Прокси
Рабочее окружение использует корпоративный прокси (`HTTP_PROXY` и `HTTPS_PROXY`),
через который `git.dementev.space` недоступен: запрос к API завершается ошибкой
`EOF`. Хост нужно добавить в `NO_PROXY`.
Разделитель — **запятая**, не точка с запятой: `tea` написан на Go, а Go
разбирает `NO_PROXY` по запятым, и хост после `;` не распознаётся.
На текущую сессию:
```powershell
$env:NO_PROXY = "$env:NO_PROXY,git.dementev.space"
```
Постоянно, в пользовательских переменных окружения (значение подхватят только
новые процессы):
```powershell
[Environment]::SetEnvironmentVariable("NO_PROXY", "$env:NO_PROXY,git.dementev.space", "User")
```
## Работа с задачами
Из рабочего дерева использовать Gitea remote `origin`:
```powershell
tea issues list --remote origin
tea issues create --remote origin
tea issues edit <index> --remote origin
tea labels list --remote origin
```
За пределами рабочего дерева явно указывать репозиторий
`ddmitry/local-transcriber` и настроенный Gitea login.
+15
View File
@@ -0,0 +1,15 @@
# Triage Labels
Инженерные навыки используют пять канонических triage-ролей. В Gitea им
соответствуют одноимённые метки.
| Роль навыка | Метка Gitea | Значение |
| --- | --- | --- |
| `needs-triage` | `needs-triage` | Требует оценки сопровождающим |
| `needs-info` | `needs-info` | Ожидает дополнительной информации от автора |
| `ready-for-agent` | `ready-for-agent` | Полностью описана и готова для автономного агента |
| `ready-for-human` | `ready-for-human` | Требует реализации человеком |
| `wontfix` | `wontfix` | Выполняться не будет |
Когда навык упоминает triage-роль, следует использовать соответствующую метку
из этой таблицы.
+262
View File
@@ -0,0 +1,262 @@
# Backlog — будущие эксперименты и направления
Список открытых направлений, которые имеют смысл, но не реализованы. Каждый пункт содержит обоснование и ссылку на источник (ADR / статья), чтобы при возврате не пришлось воспроизводить контекст с нуля.
Когда направление становится в работу — переносится в spec/план или соответствующий ADR. Когда отклоняется — остаётся в backlog с пометкой «отклонено» и причиной (для истории решений).
---
## ASR-бэкенды и модели
### Переоценка CPU-дефолта после обновления `onnx-asr` 0.12
**Проблема:** текущий CPU-путь через OpenVINO Whisper medium нестабилен на
длинных записях с тихими участками: модель генерирует правдоподобные повторы и
несуществующий текст. На файле `2026-07-29 13-58-39.mp4` разговор заканчивается
примерно на 02:28, после чего OpenVINO создаёт десятки повторяющихся блоков до
конца 59-минутной записи. Изолированный тест окна 02:00–03:30 дал одинаковую
серию из 14 повторов на `openvino-genai` 2026.0 и 2026.3; обновление движка само
по себе проблему не устраняет. Подробнее — в пункте
[«Whisper medium галлюцинации»](#whisper-medium-галлюцинации-на-длинных-файлах-с-тихими-фрагментами).
**Порядок работы:**
1. Сначала обновить совместимый стек по
[исследованию обновлений](research/2026-08-10-engine-model-updates.md): снять
ограничение `onnx-asr<0.12.0`, проверить VAD и квантованные модели; OpenVINO и
OpenVINO GenAI обновлять согласованно. Не фиксировать ONNX Runtime 1.28 для
Python 3.10.
Шаг 1 в части onnx-пути вынесен в отдельную работу —
[спека «Onnx-каталог, Python 3.13 и границы версий»](specs/2026-08-11-onnx-model-catalog.md): движок
обновляется, три модели становятся поддерживаемыми, дефолт и OpenVINO не
трогаются намеренно, чтобы сохранить точку отсчёта для будущего сравнения.
2. После интеграционных тестов провести сравнительный бенчмарк моделей. Не
менять CPU-дефолт только по model card или результату на одном файле.
3. Решение зафиксировать в ADR-007, обновить рекомендации README и только затем
менять auto-detect/defaults.
**Кандидаты:**
- `gigaam-v3-ctc` — текущий baseline для русского CPU.
- `gigaam-v3-rnnt` — контекстный декодер без пунктуации.
- `gigaam-v3-e2e-ctc` и `gigaam-v3-e2e-rnnt` — варианты с пунктуацией и
нормализацией текста.
- `gigaam-multilingual-ctc` и `gigaam-multilingual-large-ctc`, добавленные в
`onnx-asr` 0.12 — кандидаты для смешанной речи, но не априори для русского
дефолта.
- OpenVINO medium + VAD — альтернатива смене модели, сохраняющая Whisper и
пунктуацию.
- Parakeet v3 — контрольный multilingual-вариант, но не кандидат на русский
дефолт без новых данных, опровергающих ADR-005/006.
**Матрица качества:**
- те же три полные записи и четыре критерия agent-judge из
[ADR-006](adr/006-onnx-asr-backend.md): completeness, term accuracy, fluency,
summary utility;
- новый негативный пример `2026-07-29 13-58-39.mp4` с длинной тишиной;
- небольшой вручную проверенный набор сложных фрагментов для WER/CER и разбора
критичных смысловых ошибок: тихие реплики, имена компаний, латиница и
IT-термины, числа, русско-английское переключение;
- автоматические проверки потери хвоста, повторов, пустых/нулевых VAD-сегментов
и выдуманного текста на тишине.
**Матрица производительности:**
- обязательный прогон на реальном Intel Core i5 11-го поколения; точный SKU,
число ядер, объём RAM, power mode и число потоков записать вместе с результатом;
- Ryzen 7 8845H разработчика и ограничение числа потоков использовать только для
предварительного smoke-теста, не как приёмку производительности i5;
- измерять отдельно cold start/загрузку модели и warm transcription, медиану
трёх прогонов, RTFx, peak RSS и размер скачиваемой модели;
- короткий 15-минутный фрагмент нужен для итераций, полные записи 22–81 мин —
для итогового решения и проверки устойчивости.
**Почему интересно:**
- **WER 2.6% vs 13.2%** для CTC на сложных текстах — в 5 раз ниже на разговорной речи и доменной лексике (источник: SberDevices / Хабр-публикация GigaAM-v3).
- **Контекстный декодер** — структурно решает основную проблему GigaAM-CTC из ADR-006: кириллизация латиницы и искажения имён компаний (`Запромбанк``Газпромбанк`, `яндекс тим под яндекс тим``Яндекс ТимКод`). RNN-T видит контекст уже сгенерированных токенов и может «дотянуть» имена.
- **`v3_e2e_rnnt` с пунктуацией и нормализацией** — закрывает главное ограничение GigaAM-CTC, ради которого в README сейчас стоит fallback на `openvino-cpu medium` (с задокументированными в ADR-006 галлюцинациями на длинных файлах).
- **70:30 vs Whisper-large-v3** — GigaAM-v3 (CTC и RNN-T) выигрывает у `large-v3` по LLM-as-Judge (Gemini 2.5 Pro). Если переносится на наш use case — RNN-T на CPU становится сильнее GPU faster-whisper large-v3.
- **30% лучше на «новых доменах»** (callcenter-like речь, нестандартные характеристики) — это и есть домен установочных встреч.
**Tradeoff:**
- Скорость ниже CTC (RNN-T декодинг последовательный). Реалистичная оценка: 10-15× RTF на CPU вместо 17-29× у CTC. Всё ещё в 1.5-2× быстрее Whisper medium.
- Размер модели больше (~500 MB int8 против ~300 MB у CTC) — оценка, нужна верификация.
**Если подтвердится бенчмарком:**
- Если E2E RNN-T сохраняет качество и приемлемую скорость на i5 — сделать его
русским CPU-дефолтом, OpenVINO оставить явной опцией.
- Если лучший вариант зависит от языка — выбрать language-aware default:
GigaAM v3 для русского, multilingual-модель для смешанной речи.
- Если модели GigaAM проигрывают по пунктуации/смыслу — сохранить текущий
model default и лечить OpenVINO через VAD/качественный pipeline.
- Если ни один вариант не проходит порог качества и скорости — не менять
дефолт, оставить предупреждения и явный выбор backend.
---
### Canary 1B — multilingual + пунктуация на CPU
**Что:** Протестировать `nemo-canary-1b-v2` через onnx-asr на тех же 3 файлах.
**Почему:** Multilingual + пунктуация в одной модели. Кандидат на «лучшее качество за разумную скорость» для пользователей, которым нужны и не-русский контент, и пунктуация одновременно. Упомянут в [ADR-006](adr/006-onnx-asr-backend.md#открытые-вопросы--следующие-шаги).
**Tradeoff:** Тяжелее GigaAM (~1 GB vs ~300 MB), скорость на CPU ожидаемо ниже. Если RNN-T закроет потребность в пунктуации — Canary становится менее приоритетным.
---
## Качество и устойчивость
### Whisper medium галлюцинации на длинных файлах с тихими фрагментами
**Статус:** воспроизведено 2026-08-10. Помимо примеров из
[ADR-006](adr/006-onnx-asr-backend.md#класс-ошибок-whisper-medium--галлюцинации-на-длинных-файлах-с-тихими-фрагментами),
на записи `2026-07-29 13-58-39.mp4` минимальный тест 02:00–03:30 стабильно
получает 14 одинаковых сегментов после окончания речи. На 30-секундном окне
только с речью повторов нет. `openvino-genai` 2026.3 и
`no_repeat_ngram_size=3` результат не меняют. Корень проблемы — длинные
безречевые участки, которые текущий OpenVINO backend целиком передаёт в
`WhisperPipeline` без VAD.
**Возможные направления:**
- Добавить VAD перед OpenVINO WhisperPipeline и сохранить исходные таймкоды —
наиболее прямое лечение подтверждённой причины.
- Ограничить длину чанка для openvino-medium (chunk_length параметр в WhisperPipeline).
- Внедрить `compression_ratio_threshold` / `log_prob_threshold` фильтры через переписывание pipeline (как у CTranslate2). Уже частично описано в [docs/gpu.md «Качественный pipeline для OpenVINO»](gpu.md#качественный-pipeline-для-openvino).
- Переключить русский CPU-дефолт на победителя сравнительного GigaAM-бенчмарка.
- Предупреждать пользователя при `--device openvino-cpu` для файлов >30 мин.
**Приоритет:** высокий — проблема затрагивает текущий OpenVINO CPU-путь, а
целевая аудитория включает ноутбуки с Intel Core i5 11-го поколения. GigaAM v3
остаётся рабочей явной альтернативой для русского, но выбор безопасного
auto/default требует сравнительного бенчмарка.
---
### Интеллектуальное чанкование длинных файлов по паузам
**Что:** Резать длинные файлы на чанки (~90 с) не по фиксированной сетке, а по ближайшей тишине: `ffmpeg -af silencedetect=noise=-40dB:d=0.5` → парсинг stderr → выбор точки разреза в окне ±30 с вокруг целевой границы (с минимальным зазором между разрезами, чтобы не получить нулевые чанки).
**Почему:** Разрез посреди слова/фразы портит распознавание на границах чанков; разрез по паузе — нет. Потенциально смягчает класс ошибок Whisper medium на длинных файлах (потеря хвоста, блоки повторов — см. пункт выше): короткие чанки не дают декодеру «уплыть».
**Источник:** референсная реализация Parakeet-сервера (Flask, OpenAI-совместимый API), лежавшая в репо как `app.py` в период эксперимента ADR-005/006 (апрель 2026); удалена при чистке 2026-07-09 — рабочие константы: порог -40dB, мин. тишина 0.5 с, окно поиска 30 с, мин. зазор 5 с.
**Tradeoff:** дополнительный проход ffmpeg по всему файлу (silencedetect) перед транскрипцией; для часового файла — десятки секунд.
---
### Качественный pipeline для OpenVINO (temperature fallback + фильтры)
**Что:** Реализовать temperature fallback, compression_ratio и log_prob фильтры поверх OpenVINO GenAI WhisperPipeline. Эвристики — логика на Python (~50-100 строк), не зависящая от inference engine.
**Почему:** Дать Intel Arc / AMD GPU и AMD CPU то же качество, что сейчас есть только у CUDA-пользователей через CTranslate2. Полностью описано в [docs/gpu.md](gpu.md#качественный-pipeline-для-openvino).
---
## Структура транскрипта (конспекты и MoM)
Источник раздела: внешнее сравнение локального транскрипта (medium,
openvino-cpu, запись 25:59) с облачным сервисом Hypescribe, критерий —
пригодность как сырья для конспекта и протокола встречи (GPT-ревью,
2026-07-10). Итог: по смыслу локальная модель почти равна облаку
(6.5/10 против 7/10), главный разрыв — **не качество распознавания,
а структура**: разделение говорящих (2/10 против 8/10) и нарезка на
реплики. Приоритеты ревьюера: 1) смысл, 2) спикеры, 3) техтермины,
4) разбивка на фразы, 5) таймкоды. Вывод: локальная диаризация +
словарь терминов закрывают потребность в облачном сервисе для
внутренних встреч.
Сознательно вне ядра CLI (максимум — рецепт в README): второй проход
LLM для чистки текста, сопоставление Speaker N с именами — это работа
поверх готового транскрипта.
### Диаризация — разделение говорящих
**Что:** Опциональный пост-процессинг (не четвёртый бэкенд): сегменты
уже несут таймкоды; диаризация даёт интервалы «кто когда говорил»;
merge по перекрытию интервалов; formatter ломает абзац на смене спикера
и подписывает `Speaker 1:`. Ставится как extra:
`uv sync --extra diarization`.
**Почему:** Без спикеров MoM не собрать — это ключевой разрыв с облаком
по внешнему ревью, и никакое качество распознавания его не компенсирует.
Заодно естественно решает «разбивку на реплики» (приоритет №4).
**Варианты реализации (ключевое решение, нужен ADR):**
- **sherpa-onnx** — диаризация целиком на onnxruntime (сегментация
pyannote в ONNX + спикер-эмбеддинги), без torch, в духе нашего
onnx-стека и «no cloud, no API keys».
- **pyannote.audio** — стандарт качества, но тянет torch и требует
HF-токен с принятием лицензии моделей — трение с духом проекта.
**Уточнить перед запуском:** качество обоих вариантов на русской речи
и перекрывающихся репликах; скорость на CPU (диаризация — второй проход
по всему аудио); лицензии моделей сегментации/эмбеддингов.
---
### Ручка нарезки абзацев в formatter
**Что:** «Минутные простыни» в транскрипте — не свойство модели, а наши
константы группировки `_PAUSE_THRESHOLD_S = 2.0` / `_MAX_PARAGRAPH_S =
60.0` в `formatter.py` (сырых сегментов много: 23-минутная запись — 360
сегментов, ~4 с на реплику). Вынести в опцию/конфиг или уменьшить
дефолт.
**Почему откладывается:** при диаризации абзацы будут ломаться по смене
спикера естественно — сначала решить с диаризацией, чтобы не делать
ручку, которая устареет.
---
### Словарь замен технических терминов — запасной план
**Что:** Пост-обработка текста сегментов словарём замен по границам слов
(`CSW → CSV`, `софтп → SFTP`, `ямлик → YAML`, `Spark и Scale → Spark
SQL`), словарь пользовательский в `.transcriber.toml`.
**Почему запасной:** это тот же класс ошибок, что «кириллизация латиницы
и искажение имён» из [ADR-006](adr/006-onnx-asr-backend.md), и первым
его должен попробовать закрыть контекстный декодер GigaAM v3 RNN-T
(первый пункт бэклога). Заводить словарь — только если бенчмарк RNN-T
термины не вытянет.
---
## Авто-детект и UX
### Профили намерения вместо выбора модели
**Что:** Вместо `--model gigaam-v3-e2e-rnnt` пользователь выбирает намерение —
условные `ru-fast`, `ru-readable`, `mixed`, — а проект разворачивает его в пару
модель + квантизация с учётом устройства.
**Почему:** Имена onnx-моделей ничего не говорят о том, что получит
пользователь, и различие «поддерживаемая модель ≠ рекомендуемая» через них не
выражается.
**Почему откладывается:** профиль осмыслен, когда известно, какой профиль чем
закрывается — то есть после сравнительной оценки. Введение понятия раньше
данных закрепит догадку в интерфейсе. Источник:
[спека «Onnx-каталог, Python 3.13 и границы версий»](specs/2026-08-11-onnx-model-catalog.md).
---
### Включение `onnx` в `--device auto`
**Что:** После периода стабилизации `--device onnx` (несколько недель production-использования без жалоб) — рассмотреть включение в auto-detect chain.
**Порядок в chain (предложение):** CUDA → onnx (если CPU x86_64) → OpenVINO → CPU.
**Почему откладывается:** политика experimental backend — не сюрпризить существующих пользователей до накопления опыта. Источник: [ADR-006](adr/006-onnx-asr-backend.md#решение).
---
## Отклонённые направления
*(пока пусто — добавлять сюда то, что попробовали и решили не делать, с причиной)*
+181 -20
View File
@@ -37,7 +37,7 @@ OpenVINO ускоряет inference на x86 процессорах (Intel и AM
| tiny | OpenVINO/whisper-tiny-int8-ov | — |
| base | — | OpenVINO/whisper-base-fp16-ov |
| small | OpenVINO/whisper-small-int8-ov | — |
| medium | OpenVINO/whisper-medium-int8-ov | |
| medium | OpenVINO/whisper-medium-int8-ov | OpenVINO/whisper-medium-fp16-ov |
| large-v3 | OpenVINO/whisper-large-v3-int8-ov | OpenVINO/whisper-large-v3-fp16-ov |
### Результаты тестирования OpenVINO
@@ -50,7 +50,7 @@ OpenVINO ускоряет inference на x86 процессорах (Intel и AM
|---|---|---|---|
| Intel Ultra 7 255H | **122с** | **411с** | — |
| AMD Ryzen 7 8845H | 185с | 416с | 734с |
| Intel i7 (WSL2) | 171-205с | — | 658с |
| Intel i7-11800H (WSL2) | 171-205с | — | 658с |
**Ускорение vs CPU float32:** **3-6x** в зависимости от CPU.
@@ -77,6 +77,113 @@ OpenVINO ускоряет inference на x86 процессорах (Intel и AM
- **medium** — для повседневного использования и обработки ИИ. Ключевые термины верные, единичные ляпы не влияют на смысл конспекта. Оптимальный баланс скорости и качества.
- **large-v3** — для важных записей, где нужна дословная точность. Лучшая пунктуация и связность. На OpenVINO (416с) быстрее, чем medium на чистом CPU (734с) — лучшее качество при выше скорости.
## CPU бэкенд (CTranslate2 / faster-whisper)
При `--device cpu` используется faster-whisper на основе CTranslate2. Этот бэкенд медленнее
OpenVINO, но обеспечивает **лучшее качество** благодаря развитому pipeline декодирования.
### Рекомендуемый compute_type: `int8_float32`
```bash
transcribe meeting.mp4 --device cpu --compute-type int8_float32
```
`int8_float32` — int8 квантизация весов с float32 аккумулятором. Даёт **1.5x ускорение**
vs float32 при сопоставимом качестве (протестировано на русском языке с техтерминами).
| compute_type | Скорость* | Качество | Когда использовать |
|---|---|---|---|
| `int8_float32` | **~460с** | Отлично | **Рекомендуется** — лучший баланс |
| `float32` | ~880с | Отлично (эталон) | Если важна максимальная точность |
| `int8` | быстрее | Хорошо, но бывают галлюцинации | Не рекомендуется для длинных записей |
\* Замеры на AMD Ryzen 7 8845H, medium, запись 14:41, 8 потоков.
### Оптимизация потоков
CTranslate2 по умолчанию использует 4 потока. На многоядерных CPU рекомендуется
задать число потоков равным числу **физических ядер** (не виртуальных):
```bash
transcribe meeting.mp4 --device cpu --compute-type int8_float32 --threads 8
```
**Замеры (Intel i7-11800H, 8 ядер / 16 потоков, medium int8_float32, 16 мин файл):**
| --threads | Время | Ускорение |
|---|---|---|
| 0 (дефолт = 4) | 320с | baseline |
| 8 (физ. ядра) | **277с** | **+13%** |
SMT/Hyper-Threading не помогает — 16 потоков на 8-ядерном CPU медленнее, чем 8.
### Почему CPU бэкенд качественнее OpenVINO
При одной и той же модели (medium) CTranslate2 даёт заметно лучше распознавание,
чем OpenVINO. Разница **не в квантизации**, а в pipeline декодирования:
| Механизм | CTranslate2 (faster-whisper) | OpenVINO GenAI |
|---|---|---|
| Temperature fallback | ✅ до 5 попыток с ростом temperature | ❌ один проход |
| Фильтр по compression_ratio | ✅ отсекает повторы | ❌ нет |
| Фильтр по log_prob | ✅ отсекает неуверенные сегменты | ❌ нет |
| no_speech_threshold | ✅ детекция тишины | ❌ нет |
| VAD (Silero) | ✅ опционально | ❌ нет |
| condition_on_previous_text | ✅ с умным сбросом | ⚠️ базовый |
Эти эвристики критичны для русской разговорной речи с паузами и перебивками.
### Выбор между OpenVINO и CPU
| Сценарий | Рекомендация |
|---|---|
| Быстрый черновой транскрипт | `--device openvino-cpu` (int8, ~240с) |
| Качественный транскрипт для суммаризации | `--device cpu --compute-type int8_float32` (~460с) |
| Максимальная точность | `--device cpu --compute-type float32` (~880с) |
## CUDA: compute_type и качество
На NVIDIA GPU дефолт — `float16`, и для большинства случаев это оптимальный выбор.
### Результаты тестирования compute_type на GPU (RTX 3060 Laptop, 6 GB)
**Скорость (файл 16 мин, русский язык):**
| Модель | float16 | int8_float32 | int8_float16 |
|---|---|---|---|
| medium | **53с** (~18x) | 64с (~15x) | 54с (~18x) |
| large-v3 | **86с** (~11x) | 113с (~8.5x) | 125с (~7.7x) |
**Скорость (файл 46 мин, русский язык):**
| Модель | float16 |
|---|---|
| medium | **127с** (~22x реалтайм) |
| large-v3 | 271с (~10x реалтайм) |
**Качество (файл 16 мин):**
| Модель | float16 | int8_float32 | int8_float16 |
|---|---|---|---|
| medium | **Отлично** | **Отлично** (≈float16) | Хорошо (1-2 ошибки) |
| large-v3 | Хорошо (ед. галлюцинации) | **Плохо** (повтор "Ага" ×18) | **Неприемлемо** (мусор, иероглифы) |
**Качество (файл 46 мин):**
| Модель | float16 | Проблемы |
|---|---|---|
| medium | **Отлично** | Единичные ляпы ("email" вместо "ML"), стабилен |
| large-v3 | **Плохо** | Галлюцинации на 3+ языках (китайский, арабский), повторы фраз, "Аминь" вместо "Угу" |
### Выводы по CUDA compute_type
- **`float16` — оптимальный дефолт**: самый быстрый и стабильный
- **`int8_float32` не дал выигрыша**: на 20% медленнее float16, качество для medium сопоставимо, для large-v3 — деградация
- **`int8_float16`**: по скорости ≈ float16 для medium, но large-v3 даёт мусор
- **`int8`**: рискует галлюцинациями на large-v3 и длинных записях (повтор фраз ×25)
- **medium устойчивее к квантизации**, чем large-v3 — все варианты compute_type дают приемлемый результат
- **large-v3 на длинных записях (>20 мин)**: галлюцинации даже с float16 — medium надёжнее
## Настройка по платформам
### Linux / WSL2 (x86_64)
@@ -98,45 +205,99 @@ winget install -e --id Nvidia.CUDA --version 12.9 # требует запус
## Совместимость GPU
| GPU | VRAM | medium float16 | large-v3 float16 | Рекомендация |
|-----|------|---------------|-----------------|--------------|
| RTX 3060 | 6 GB | ✅ | ✅ | medium float16 (дефолт) |
| RTX 4050 | 6 GB | ✅ | ✅ | medium float16 |
| Quadro M3000M | 4 GB | ✅ | ⚠️ tight | medium float16 или int8 |
| GPU | VRAM | CC | medium float16 | large-v3 float16 | Рекомендация |
|-----|------|----|---------------|-----------------|--------------|
| RTX 3060 | 6 GB | 8.6 | ✅ | ✅ | medium float16 (дефолт) |
| RTX 4050 | 6 GB | 8.9 | ✅ | ✅ | medium float16 |
| Quadro M3000M | 4 GB | 5.0 | ❌ | ❌ | `--device cpu` или `openvino` |
> **Quadro M3000M и другие GPU с CC < 7.0 (Maxwell, Pascal)**: CUDA не работает —
> float16 требует CC ≥ 7.0, float32 падает с ошибкой (CTranslate2 4.x не включает
> sm_50/sm_60 в пребилды). Используйте `--device cpu --compute-type int8_float32`
> или `--device openvino`.
## Ожидаемая скорость
Замеры на RTX 3060 Laptop (6 GB) и Intel CPU (WSL2):
| Конфигурация | 16 мин файл | 42 мин файл | Отн. скорость |
| Конфигурация | 16 мин файл | 46 мин файл | Отн. скорость |
|-------------|-------------|-------------|---------------|
| GPU + medium float16 | ~35с | ~133с | ~19x реалтайм |
| GPU + large-v3 float16 | ~90с | ~350с | ~7x реалтайм |
| GPU + medium float16 | **53с** | **127с** | **~18-22x реалтайм** |
| GPU + large-v3 float16 | 86с | 271с | ~10-11x реалтайм |
| GPU + medium int8_float32 | 64с | — | ~15x реалтайм |
| GPU + medium int8_float16 | 54с | — | ~18x реалтайм |
| **OpenVINO + small int8** | **93с** | **153с** | **~10-16x реалтайм** |
| **OpenVINO + medium int8** | **171-205с** | **413с** | **~4-6x реалтайм** |
| **OpenVINO + large-v3 fp16** | **416с** | — | **~2.3x реалтайм** |
| **CPU + medium int8_float32** | **~460с*** | — | **~2x реалтайм** |
| CPU + medium float32 | 658с (11 мин) | ~26 мин | ~1.5x реалтайм |
| CPU + large-v3 int8 | 839с (14 мин) | ~37 мин | ~1:1 реалтайм |
\* Замер CPU int8_float32 на AMD Ryzen 7 8845H (8 потоков), файл 14:41.
## Результаты тестирования качества
Тесты проведены на реальных записях рабочих созвонов (русский язык, технические термины:
SQL, PostgreSQL, Greenplum, Airflow, ClickHouse, Docker, CDR, GTP, MAP).
| Конфигурация | Качество (длинная запись, 42 мин) | Проблемы |
|---|---|---|
| large-v3 int8 GPU | Плохо | Галлюцинации (фразы ×25), потеря контента |
| large-v3 float16 GPU | Отлично | — |
| medium float16 GPU | Хорошо | Редкие мелкие ляпы в терминах |
| medium float32 CPU | Хорошо | Сопоставимо с large-v3 int8, без галлюцинаций |
| large-v3 int8 CPU | Хорошо | Без галлюцинаций (на коротких файлах) |
| Конфигурация | Качество (короткие, ≤16 мин) | Качество (длинные, >40 мин) | Проблемы |
|---|---|---|---|
| medium float16 GPU | **Отлично** | **Отлично** | Единичные ляпы в терминах ("email" вместо "ML") |
| medium int8_float32 GPU | **Отлично** | — | Качество ≈ float16, но на 20% медленнее |
| medium int8_float16 GPU | Хорошо | — | 1-2 ошибки |
| large-v3 float16 GPU | Хорошо | **Плохо** | Галлюцинации на длинных записях (мусор на 3+ языках) |
| large-v3 int8_float32 GPU | **Плохо** | — | Повтор фраз ("Ага" ×18), искажения |
| large-v3 int8_float16 GPU | **Неприемлемо** | — | Мусор, китайские/арабские символы, потеря текста |
| large-v3 int8 GPU | **Плохо** | **Плохо** | Галлюцинации (фразы ×25), потеря контента |
| medium int8_float32 CPU | **Отлично** | — | Качество ≈ float32, на уровне облачных сервисов |
| medium float32 CPU | **Отлично** | Хорошо | Эталон качества |
| large-v3 int8 CPU | Хорошо | — | Без галлюцинаций (на коротких файлах) |
### Ключевые выводы
1. **Указание языка (`--language ru`) критично** — auto-detect может ошибиться и выдать мусор
2. **float16/float32 стабильнее int8** — особенно на записях >20 минут
3. **medium + float16 на GPU — лучший баланс** скорости и качества для повседневного использования
4. **large-v3 + float16 на GPU** — для максимального качества важных записей
2. **medium + float16 на GPU — лучший баланс** скорости и качества для любых записей
3. **large-v3 ненадёжен на длинных записях (>20 мин)** — галлюцинации даже с float16;
medium стабильнее на любой длине
4. **medium устойчив к квантизации** — все варианты compute_type дают приемлемый результат;
large-v3 деградирует катастрофически при любом int8
5. **CPU: int8_float32 — лучший баланс** — 1.5x быстрее float32 при том же качестве
6. **OpenVINO быстрее, но CPU бэкенд качественнее** — разница в pipeline декодирования
(temperature fallback, фильтры галлюцинаций), а не в квантизации
## Потенциальные направления развития
### Качественный pipeline для OpenVINO
Сейчас качественный pipeline декодирования (temperature fallback, фильтры галлюцинаций,
VAD) доступен только через CTranslate2, т.е. на CPU и NVIDIA CUDA. Пользователи Intel Arc
и AMD Radeon получают скорость OpenVINO, но без защиты от галлюцинаций.
**Решение**: реализовать temperature fallback, compression_ratio и log_prob фильтры
поверх OpenVINO GenAI WhisperPipeline. Эти эвристики — логика на Python (~50-100 строк),
не зависящая от inference engine. Это даст Intel Arc / AMD GPU то же качество,
что сейчас есть только у CUDA-пользователей.
### CUDA: int8_float32 / int8_float16 — протестировано
На CPU `int8_float32` показал качество на уровне float32 при 1.5x ускорении.
На GPU (RTX 3060) результат другой: для **medium** int8_float32 даёт сопоставимое
качество, но на 20% медленнее float16 — выигрыша нет. Для **large-v3** любой int8
вариант (включая int8_float32) вызывает галлюцинации — float32 аккумулятор не спасает
глубокую модель на GPU. Вывод: **float16 остаётся оптимальным дефолтом для CUDA**.
### NVIDIA Parakeet TDT — проверено, отклонено
Оценивалась гипотеза: multilingual Parakeet TDT 0.6B v3 через `onnx-asr` может обойти
Whisper medium int8 на x86 CPU. На двух 15-минутных отрывках реальных русских ИТ-встреч
(Intel i7-11800H) Parakeet показал **2.7x преимущество по скорости** и **-35% RAM**, но
**качество ниже** — систематическая редукция тихих реплик менти до `Mm-hmm`, вставки
кусков польского/испанского, сильное искажение ИТ-терминов. Попытка тюнинга Silero VAD
threshold не помогла — проблема inherent для модели на тихой русской речи.
Код бэкенда не вмержен в master; эксперимент сохранён в ветке
`feature/parakeet-backend`. Подробности и когда стоит пересмотреть —
[ADR-005](adr/005-parakeet-evaluation.md).
## Troubleshooting
@@ -0,0 +1,53 @@
# Обновления движков и моделей на 2026-08-10
Исследование выполнено по первичным источникам: официальным release notes, changelog, PyPI и model cards Hugging Face. Текущее состояние проекта зафиксировано в [`pyproject.toml`](../../pyproject.toml) и [`uv.lock`](../../uv.lock).
## Краткий вывод
В проекте уже используется актуальный `faster-whisper` 1.2.1, однако связанные движки и модельный каталог можно обновить. Наиболее полезная последовательность: согласованно поднять OpenVINO и OpenVINO GenAI до 2026.3, разрешить `onnx-asr` 0.12 и проверить VAD, обновить CTranslate2 до 4.8.1, затем добавить `large-v3-turbo` для FasterWhisper и OpenVINO. ONNX Runtime 1.28 нельзя безусловно фиксировать, пока проект поддерживает Python 3.10, поскольку эта версия ORT требует Python 3.11 или новее ([ONNX Runtime 1.28.0 на PyPI](https://pypi.org/project/onnxruntime/1.28.0/)).
## Движки
| Компонент | Сейчас в проекте | Актуальная стабильная версия | Существенные изменения и риски |
|---|---:|---:|---|
| faster-whisper | 1.2.1 | 1.2.1 | Проект уже на последнем релизе. В 1.2.1 обновлён Silero VAD до v6, доработаны retry Hugging Face Hub и `clip_timestamps`; в 1.2.0 добавлена поддержка `distil-large-v3.5` ([официальные releases](https://github.com/SYSTRAN/faster-whisper/releases), [PyPI](https://pypi.org/project/faster-whisper/)). Для актуального GPU-стека документация указывает CUDA 12 и cuDNN 9; для старых комбинаций требуются специальные версии CTranslate2 ([официальная установка](https://github.com/SYSTRAN/faster-whisper#gpu)). |
| CTranslate2 | 4.7.1 | 4.8.1 | В 4.8.0 `PACKED_GEMM` включён по умолчанию для Intel MKL; 4.8.1 исправляет heap overflow при загрузке модели и аварийное деление на ноль в Whisper `align()` при отсутствии кадров ([официальный changelog](https://github.com/OpenNMT/CTranslate2/blob/master/CHANGELOG.md), [релиз 4.8.1](https://github.com/OpenNMT/CTranslate2/releases/tag/v4.8.1), [PyPI](https://pypi.org/project/ctranslate2/)). Публичного breaking API для используемого пути не заявлено, но из-за нативных библиотек нужно проверить Windows CPU, Linux CUDA и проектный CUDA bootstrap. Требование cuDNN 9 появилось в CTranslate2 4.5.0 ([changelog](https://github.com/OpenNMT/CTranslate2/blob/master/CHANGELOG.md#450)). |
| OpenVINO | 2026.0.0 | 2026.3.0 | В 2026.3 добавлены общий `ASRPipeline`, Qwen3-ASR и метрики задержки стадий распознавания; в ветке 2026.x также появились word-level timestamps и определённый язык в результатах Whisper ([официальные release notes](https://docs.openvino.ai/2026/about-openvino/release-notes-openvino.html), [PyPI](https://pypi.org/project/openvino/)). Начиная с 2026.0 удалена поддержка устаревшего stateless Whisper decoder, поэтому модели должны быть stateful; та же версия требует минимум AVX2, использует manylinux_2_28 и больше не поддерживает CentOS 7 ([release notes 2026.0](https://docs.openvino.ai/2026/about-openvino/release-notes-openvino.html#openvino-2026-0-0)). |
| openvino-genai | 2026.0.0.0 | 2026.3.0.0 | OpenVINO GenAI необходимо обновлять вместе с OpenVINO: официальная документация требует совпадения `major.minor.patch` у OpenVINO, OpenVINO Tokenizers и GenAI, иначе возможны ABI/import errors. PyPI-пакеты собраны с `_GLIBCXX_USE_CXX11_ABI=0`, а архивы C++ — с ABI=1, поэтому смешивать PyPI GenAI с OpenVINO из C++ archive нельзя ([официальная страница PyPI](https://pypi.org/project/openvino-genai/), [официальные releases](https://github.com/openvinotoolkit/openvino.genai/releases)). |
| onnx-asr | 0.11.0 | 0.12.0 | Обновление сейчас **явно заблокировано** зависимостью `onnx-asr[cpu,hub]>=0.11.0,<0.12.0` в [`pyproject.toml`](../../pyproject.toml). В 0.12 добавлены GigaAM Multilingual CTC/Large CTC, свёрточные ONNX preprocessors с ускорением CUDA EP и ORT fallback; также исправлены сегменты нулевой длины после VAD, что напрямую относится к используемому проектом `.with_vad()` ([релиз 0.12.0](https://github.com/istupakov/onnx-asr/releases/tag/v0.12.0), [официальные release notes](https://istupakov.github.io/onnx-asr/release-notes/), [PyPI](https://pypi.org/project/onnx-asr/)). Breaking Python API не объявлен, но проект должен проверить загрузку квантованных моделей, VAD и форму возвращаемых сегментов. |
| ONNX Runtime | 1.24.3 | 1.28.0 | В 1.28 обновлены ONNX и protobuf и включены security fixes, в том числе проверка границ в `WhisperDecoderSubgraph` ([официальный релиз 1.28.0](https://github.com/microsoft/onnxruntime/releases/tag/v1.28.0), [PyPI](https://pypi.org/project/onnxruntime/1.28.0/)). Версия 1.28 требует Python 3.11+, тогда как проект допускает Python 3.10, поэтому нужен условный lock/constraint либо осознанное повышение минимальной версии Python ([метаданные PyPI 1.28.0](https://pypi.org/project/onnxruntime/1.28.0/)). Для GPU-пакетов ORT 1.27+ используется CUDA 13.0 и cuDNN 9; major-версии CUDA и cuDNN должны совпадать с runtime ([официальная матрица CUDA EP](https://onnxruntime.ai/docs/execution-providers/CUDA-ExecutionProvider.html#requirements)). |
## Модели и варианты
### Whisper large-v3-turbo
`whisper-large-v3-turbo` — мультиязычная модель на 99 языков с 809 млн параметров вместо 1550 млн у `large-v3`; число decoder layers сокращено с 32 до 4, что заметно ускоряет вывод ценой небольшой потери качества ([официальная model card OpenAI](https://huggingface.co/openai/whisper-large-v3-turbo)). Это наиболее полезный новый вариант для русского и смешанного аудио.
FasterWhisper уже содержит стандартные aliases `large-v3-turbo` и `turbo` в собственной таблице моделей ([официальный `utils.py`](https://github.com/SYSTRAN/faster-whisper/blob/master/faster_whisper/utils.py)); готовая CTranslate2-конверсия опубликована как [`dropbox-dash/faster-whisper-large-v3-turbo`](https://huggingface.co/dropbox-dash/faster-whisper-large-v3-turbo). Проект использует собственную ограниченную таблицу aliases, поэтому модель нужно добавить явно либо перейти на стандартное разрешение имён FasterWhisper ([текущая реализация проекта](../../src/local_transcriber/backends/faster_whisper.py)).
Для OpenVINO опубликованы официальные варианты [`whisper-large-v3-turbo-fp16-ov`](https://huggingface.co/OpenVINO/whisper-large-v3-turbo-fp16-ov) и [`whisper-large-v3-turbo-int8-ov`](https://huggingface.co/OpenVINO/whisper-large-v3-turbo-int8-ov). Их model cards требуют OpenVINO 2026.1 или новее, поэтому текущий OpenVINO 2026.0 недостаточен; после согласованного обновления до 2026.3 можно добавить общий alias `turbo` для FasterWhisper и OpenVINO.
### GigaAM Multilingual
`onnx-asr` 0.12 добавляет модели `gigaam-multilingual-ctc` и `gigaam-multilingual-large-ctc` ([официальный релиз 0.12.0](https://github.com/istupakov/onnx-asr/releases/tag/v0.12.0), [документация использования](https://istupakov.github.io/onnx-asr/usage/)). Конвертированные репозитории опубликованы как [`gigaam-multilingual-ctc-onnx`](https://huggingface.co/istupakov/gigaam-multilingual-ctc-onnx) и [`gigaam-multilingual-large-ctc-onnx`](https://huggingface.co/istupakov/gigaam-multilingual-large-ctc-onnx), исходная модель — [`ai-sage/GigaAM-Multilingual`](https://huggingface.co/ai-sage/GigaAM-Multilingual). Они полезны для смешанной речи на поддерживаемых пяти языках, но требуют снятия ограничения `<0.12.0`.
### GigaAM v3 E2E
Текущий ONNX-репозиторий GigaAM v3 содержит не только CTC/RNNT, но и E2E-варианты с пунктуацией и нормализацией текста ([официальная model card `istupakov/gigaam-v3-onnx`](https://huggingface.co/istupakov/gigaam-v3-onnx)). E2E-вариант может улучшить читаемость русского текста, но меняет семантику вывода относительно текущего `gigaam-v3-ctc`; перед заменой default нужны сравнительные тесты транскрипции и форматирования.
### Distil-Whisper large-v3.5
`distil-large-v3.5` поддерживается faster-whisper начиная с 1.2.0 ([официальные releases](https://github.com/SYSTRAN/faster-whisper/releases)); доступны [исходная модель](https://huggingface.co/distil-whisper/distil-large-v3.5) и [CTranslate2-конверсия](https://huggingface.co/distil-whisper/distil-large-v3.5-ct2). Модель предназначена только для английского языка, поэтому она не подходит как русский default, но может быть отдельной English-only опцией.
### Qwen3-ASR как R&D
OpenVINO 2026.3 добавляет раннюю поддержку Qwen3-ASR через новый общий `ASRPipeline` ([официальные release notes 2026.3](https://docs.openvino.ai/2026/about-openvino/release-notes-openvino.html#openvino-2026-3-0)). Текущий backend проекта построен вокруг `WhisperPipeline` и Whisper-совместимых сегментов ([текущая реализация](../../src/local_transcriber/backends/openvino.py)), поэтому Qwen3-ASR не является drop-in обновлением модели: это отдельная R&D-задача с новым интерфейсом, модельным контрактом и тестами качества.
## Приоритет действий
1. Согласованно обновить `openvino` до 2026.3.0 и `openvino-genai` до 2026.3.0.0, не смешивая источники сборок; проверить существующие stateful Whisper-модели, CPU/GPU и минимальные платформенные требования ([совместимость GenAI](https://pypi.org/project/openvino-genai/), [release notes OpenVINO](https://docs.openvino.ai/2026/about-openvino/release-notes-openvino.html)).
2. Убрать блокирующее ограничение `<0.12.0`, поднять `onnx-asr` до 0.12.0 и прогнать интеграционные тесты VAD, сегментов и квантованных GigaAM-моделей ([релиз 0.12.0](https://github.com/istupakov/onnx-asr/releases/tag/v0.12.0)).
3. Обновить CTranslate2 с 4.7.1 до 4.8.1 и проверить Windows CPU/Linux CUDA, включая CUDA bootstrap ([changelog](https://github.com/OpenNMT/CTranslate2/blob/master/CHANGELOG.md)).
4. Добавить alias `turbo` и репозитории `large-v3-turbo` для FasterWhisper и OpenVINO после обновления OpenVINO ([OpenAI model card](https://huggingface.co/openai/whisper-large-v3-turbo), [OpenVINO FP16](https://huggingface.co/OpenVINO/whisper-large-v3-turbo-fp16-ov), [OpenVINO INT8](https://huggingface.co/OpenVINO/whisper-large-v3-turbo-int8-ov)).
5. Не фиксировать ONNX Runtime 1.28 для всех окружений, пока поддерживается Python 3.10; сначала выбрать условные зависимости либо официально поднять Python floor ([PyPI 1.28.0](https://pypi.org/project/onnxruntime/1.28.0/)).
6. Рассматривать GigaAM Multilingual, GigaAM v3 E2E и Qwen3-ASR как отдельные эксперименты с качеством и совместимостью, а `distil-large-v3.5` — только как English-only профиль ([GigaAM Multilingual](https://huggingface.co/ai-sage/GigaAM-Multilingual), [GigaAM v3 ONNX](https://huggingface.co/istupakov/gigaam-v3-onnx), [OpenVINO 2026.3](https://docs.openvino.ai/2026/about-openvino/release-notes-openvino.html#openvino-2026-3-0), [Distil-Whisper](https://huggingface.co/distil-whisper/distil-large-v3.5)).
+160
View File
@@ -0,0 +1,160 @@
# Onnx-каталог, Python 3.13 и границы версий
## Проблема
Смешанная русско-английская речь и отсутствие пунктуации у `gigaam-v3-ctc`
известные ограничения onnx-пути ([ADR-006](../adr/006-onnx-asr-backend.md)). В
`onnx-asr` 0.12 появились модели, которые их адресуют, но обновление
заблокировано ограничением `onnx-asr[cpu,hub]>=0.11.0,<0.12.0` в
`pyproject.toml`.
Состояние движков и первичные источники —
[исследование обновлений](../research/2026-08-10-engine-model-updates.md).
## Что делаем
Снимаем ограничение версии, поднимаем `onnx-asr` до 0.12 и делаем
поддерживаемыми три модели:
- `gigaam-multilingual-ctc` — смешанная речь с англоязычными терминами;
- `gigaam-v3-e2e-ctc` и `gigaam-v3-e2e-rnnt` — пунктуация и нормализация текста.
Каталог моделей в бэкенде хранит, помимо имени, опубликованные для модели
квантизации — сейчас такого места нет, потому что алиасы отображают имя в имя.
Расширять существующую таблицу или заводить рядом отдельную структуру —
решается при реализации. Passthrough сырых имён onnx-asr сохраняется: он не
мешает и оставляет дверь для экспериментов.
Заодно поднимаем минимальную версию Python до 3.13 и ONNX Runtime до последней
доступной (1.28 на момент написания). Python 3.10 в режиме security-only и
снимается с поддержки в октябре 2026, а ORT 1.28 требует минимум 3.11 — так что
подъём floor и обновление рантайма идут вместе. Следствие: зависимость `tomli` и
условный импорт в `config.py` становятся мёртвым кодом и убираются — начиная с
3.11 есть `tomllib`.
Floor берётся сразу 3.13, а не минимально достаточный 3.11: любой подъём версии
всё равно требует прогона на всех трёх платформах, и дорого именно тестирование,
а не строка в `pyproject.toml`. Один переезд вместо двух. Проект ставится там,
где доступны uv и PyPI, поэтому uv сам поднимет нужный интерпретатор; закрытых
контуров и оффлайн-зеркал среди пользователей нет.
Рабочая версия фиксируется файлом `.python-version` (сейчас его в репозитории
нет). Floor разрешает любой интерпретатор от 3.13 и выше, а пин делает окружение
одинаковым на разных машинах — иначе расхождение вылезает именно тогда, когда
что-то отлаживаешь.
Колёса cp313 проверены для всех нативных зависимостей и всех трёх целевых
платформ — Windows, Linux, macOS: `openvino-genai` 2026.0.0.0, `ctranslate2`
4.7.1, `onnxruntime` 1.28.0. На macOS `openvino-genai` не ставится по
существующему маркеру `sys_platform != 'darwin'`, так что там остаются
faster-whisper на CPU и onnx-путь — это не меняется этой работой, но стоит
помнить, раз появились пользователи на Mac.
GPU-пакеты ORT с их требованием CUDA 13 нас не касаются: CUDA идёт через
CTranslate2, а onnx-путь ставится в CPU-варианте.
## Границы версий
Каждая прямая зависимость получает верхнюю границу по мажорной версии. Причина
не в осторожности вообще, а в канале установки: README предлагает
`uv tool install git+…`, а этот путь резолвит зависимости заново из метаданных
пакета — `uv.lock` в колесо не попадает и не участвует. Диапазоны в
`[project.dependencies]` — единственное, что ограничивает версии на машине
пользователя. Установки в разные месяцы иначе дают разные наборы библиотек при
одном и том же коде, и такие расхождения дороже разбирать, чем поднимать
границы вручную.
Три случая, которые сами собой не решаются:
- **`onnxruntime` объявляется прямой зависимостью с границей**, хотя проект его
не импортирует. `[tool.uv] constraint-dependencies` для этого не годится:
по документации uv он действует только когда uv резолвит сам проект, и
игнорируется, когда пакет ставят как зависимость.
- **`nvidia-cublas-cu12` получает границу по мажорной версии.**
`_cuda_bootstrap.py` преднагружает `libcublas.so.12` по точному soname
([ADR-001](../adr/001-cuda-bootstrap.md)); мажорный апгрейд даёт другой soname
и ломает bootstrap при неизменном коде.
- **`openvino-genai` фиксируется на проверенной линии 2026.0**, а не просто «до
следующего года». Иначе свежая установка подтянет 2026.3 и незаметно поменяет
ту самую точку отсчёта, которую мы договорились не трогать. Границу снимает
отдельная работа по обновлению OpenVINO — осознанно.
Конкретные номера берутся из `uv.lock` при реализации.
## Чего не делаем
- Не меняем модель по умолчанию и auto-detect: `gigaam-v3` остаётся дефолтом
`--device onnx`. Решение о CPU-дефолте требует замеров на целевом Intel Core
i5, которого сейчас нет в доступе.
- Не обновляем OpenVINO, OpenVINO GenAI и CTranslate2. Whisper medium на
OpenVINO — то, с чем сравниваются новые модели; менять его движок
одновременно значит потерять точку отсчёта. Верхние границы версий им при
этом проставляются — см. «Границы версий»; это фиксация текущего состояния, а
не обновление.
- Не добавляем алиасы `large-v3-turbo`, диаризацию, чанкование по паузам и
словарь замен терминов — отдельные пункты [backlog](../backlog.md).
## Ловушки
- **`compute_type` разрешается по устройству, а не по модели.**
`DEVICE_DEFAULTS["onnx"]` даёт `int8` независимо от модели, а `int8`
опубликован не для всех. Для модели без запрошенной квантизации проект должен
взять доступную и сказать об этом; явно заданное пользователем значение
остаётся ошибкой, а не тихой подменой. Явным считается и значение из
`.transcriber.toml`, не только флаг CLI — подстановка допустима лишь там, где
квантизацию выбрал сам проект. Это единственное изменение в каскаде
конфигурации.
- **Язык у multilingual-модели.** Проект по умолчанию форсирует `ru`, а интерес
— как раз смешанная речь. Надо посмотреть, принимает ли модель подсказку языка
или определяет сама, и описать правило в README.
- **E2E меняет форму текста.** Пунктуация и нормализация могут повлиять на
группировку абзацев в `formatter.py` и на предупреждения `quality.py`,
написанные под поведение Whisper.
- **VAD.** В 0.12 исправлены сегменты нулевой длины после VAD — проект
оборачивает модель в `.with_vad()`, так что изменение касается нас напрямую.
## Как проверяем
Работа идёт в отдельной ветке (по конвенции репозитория — `feature/…`), в
`master` вливается только после ручной проверки. Причина не в процессе ради
процесса: переезд на 3.13 пересобирает окружение целиком, и откатывать это на
основной ветке неприятно. Ветка позволяет держать рабочий `master` под рукой,
пока новое окружение не подтвердилось.
Внутри ветки смена окружения и смена библиотеки проверяются раздельно: сначала
Python и зависимости при неизменных моделях — поведение обязано остаться
прежним, — и только потом `onnx-asr` 0.12 с новыми моделями. Иначе при первой же
странности подозреваемых окажется десяток и разделить их будет нечем, CI тут не
поможет.
Проект домашний, CI нет, репрезентативные записи приватны и на разных ноутбуках
разные — автоматическая приёмка невозможна в принципе, уверенность держится на
ручных прогонах. Что она покрывает и чего не покрывает:
- существующие тесты замоканы ([`AGENTS.md`](../../AGENTS.md)) и подтверждают
CLI, конфиг и форматирование, но не работоспособность движка: поломку ORT или
onnx-asr видно только на реальной записи;
- ручные прогоны делаются на Windows и Linux, по всем трём путям — onnx,
openvino, cuda;
- **macOS не проверяется вовсе.** Колёса cp313 там опубликованы, но это
наличие, а не работоспособность; при жалобе с Mac исходить из того, что путь
не валидировался;
- **скорость на Intel Core i5 не измерялась.** Замеры делаются на Ryzen 7
8845H и записываются как наблюдение с указанием CPU — для решения о
CPU-дефолте этого недостаточно;
- **качество моделей друг относительно друга не измерялось.** Ручная проверка
отвечает на вопрос «работает ли», а не «лучше ли»; сравнение — отдельная
работа, см. [backlog](../backlog.md).
Пошаговый чеклист и практика прогона нужны только на время работ и живут в
задаче трекера
([#1](https://git.dementev.space/ddmitry/local-transcriber/issues/1)), а не в
этом документе.
## Документация
README: три модели в таблицу ONNX-моделей. Рекомендация остаётся прежней —
`gigaam-v3` для русского — пока нет данных, чтобы её менять; у новых моделей
стоит оговорка, что скорость на слабых CPU не измерялась. Требование Python
правится в [`docs/PRD.md`](../PRD.md) — раздел «Требования» и таблица
технологий; в README версии Python не упоминаются.
+1
View File
@@ -12,6 +12,7 @@ dependencies = [
"nvidia-cublas-cu12>=12.4; sys_platform == 'linux' and platform_machine == 'x86_64'",
"openvino-genai>=2025.0; sys_platform != 'darwin' and (platform_machine == 'x86_64' or platform_machine == 'AMD64')",
"tomli>=2.0; python_version < '3.11'",
"onnx-asr[cpu,hub]>=0.11.0,<0.12.0",
]
[project.scripts]
@@ -25,6 +25,15 @@ def get_backend(device: str, *, compute_type_explicit: bool = True) -> Backend:
ov_device=device, compute_type_explicit=compute_type_explicit
)
if device == "onnx":
try:
from .onnx_asr import OnnxAsrBackend
except ImportError:
raise ValueError(
"onnx-asr бэкенд недоступен. Установите: pip install onnx-asr[cpu,hub]"
) from None
return OnnxAsrBackend()
# cuda, cpu и всё остальное → faster-whisper
from .faster_whisper import FasterWhisperBackend
+5 -1
View File
@@ -30,8 +30,12 @@ class Backend(Protocol):
model_path: str,
device: str,
compute_type: str,
cpu_threads: int = 0,
) -> Any:
"""Создаёт модель. Возвращает backend-специфичный объект."""
"""Создаёт модель. Возвращает backend-специфичный объект.
cpu_threads: число потоков для CPU inference (0 = дефолт библиотеки).
"""
...
def transcribe(
@@ -84,10 +84,17 @@ class FasterWhisperBackend:
model_path: str,
device: str,
compute_type: str,
cpu_threads: int = 0,
) -> Any:
"""Создаёт WhisperModel."""
"""Создаёт WhisperModel.
cpu_threads: число потоков для CPU inference (0 = дефолт библиотеки, обычно 4).
"""
try:
return WhisperModel(model_path, device=device, compute_type=compute_type)
return WhisperModel(
model_path, device=device, compute_type=compute_type,
cpu_threads=cpu_threads,
)
except ImportError as exc:
if _is_missing_socksio_error(exc):
raise RuntimeError(
+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)
+3 -1
View File
@@ -20,6 +20,7 @@ MODEL_REPOS: dict[tuple[str, str], str] = {
("base", "fp16"): "OpenVINO/whisper-base-fp16-ov",
("small", "int8"): "OpenVINO/whisper-small-int8-ov",
("medium", "int8"): "OpenVINO/whisper-medium-int8-ov",
("medium", "fp16"): "OpenVINO/whisper-medium-fp16-ov",
("large-v3", "int8"): "OpenVINO/whisper-large-v3-int8-ov",
("large-v3", "fp16"): "OpenVINO/whisper-large-v3-fp16-ov",
}
@@ -105,8 +106,9 @@ class OpenVINOBackend:
model_path: str,
device: str,
compute_type: str,
cpu_threads: int = 0,
) -> Any:
"""Создаёт WhisperPipeline."""
"""Создаёт WhisperPipeline. cpu_threads не используется (OpenVINO управляет сам)."""
import openvino_genai as ov_genai
ov_dev = self._resolve_ov_device()
+121 -5
View File
@@ -9,9 +9,23 @@ from rich.console import Console
from rich.status import Status
from .config import apply_device_defaults, load_config, resolve_defaults
from .formatter import format_transcript, write_transcript
from .context_menu import install_menu as install_context_menu
from .context_menu import uninstall_menu as uninstall_context_menu
from .formatter import (
format_duration,
format_timestamp,
format_transcript,
write_transcript,
)
from .quality import (
TAIL_GAP_WARN_S,
RepetitionBlock,
find_repetition_blocks,
tail_gap,
)
from .transcriber import (
Segment,
TranscribeResult,
_is_cuda_error,
_transcribe_file,
load_model,
@@ -43,9 +57,62 @@ def _format_device_info(device_used: str) -> str:
return "CPU"
def _format_repetition_blocks(
blocks: list[RepetitionBlock],
use_hours: bool,
) -> str:
"""Формирует краткое описание блоков повторов для консоли."""
rendered = [
f"[{format_timestamp(block.start, use_hours=use_hours)} - "
f"{format_timestamp(block.end, use_hours=use_hours)}] ({block.count}×)"
for block in blocks[:3]
]
summary = "; ".join(rendered)
remaining = len(blocks) - 3
if remaining > 0:
summary = f"{summary} (+ ещё {remaining})"
return summary
def _print_quality_warnings(result: TranscribeResult, file_name: str | None = None) -> None:
"""Печатает предупреждения о возможной потере содержания."""
is_batch = file_name is not None
use_hours = result.duration > 3600
gap = tail_gap(result)
if gap > TAIL_GAP_WARN_S:
covered = format_duration(result.segments[-1].end)
total = format_duration(result.duration)
message = (
f"транскрипт покрывает {covered} из {total}"
"возможна потеря хвоста записи"
)
if is_batch:
console.print(f" {file_name}: {message}", style="yellow")
else:
console.print(
f"Внимание: {message}. Попробуйте другой --device.",
style="yellow",
)
blocks = find_repetition_blocks(result.segments)
if blocks:
message = (
f"блоки повторов: {_format_repetition_blocks(blocks, use_hours)} "
"— возможны галлюцинации модели"
)
if is_batch:
console.print(f" {file_name}: {message}", style="yellow")
else:
console.print(
f"Внимание: {message}. Попробуйте другой --device.",
style="yellow",
)
@app.command()
def main(
files: list[Path] = typer.Argument(..., help="Пути к аудио/видеофайлам"),
files: list[Path] | None = typer.Argument(None, help="Пути к аудио/видеофайлам"),
model: str | None = typer.Option(
None, "--model", "-m", show_default=False, help="Модель Whisper [по умолч.: medium]"
),
@@ -55,19 +122,60 @@ def main(
output: Path | None = typer.Option(None, "--output", "-o", help="Путь к выходному файлу"),
device: str | None = typer.Option(
None, "--device", "-d", show_default=False,
help="Устройство (auto|cpu|cuda|openvino|openvino-gpu|openvino-cpu) [по умолч.: auto]"
help="Устройство (auto|cpu|cuda|openvino|openvino-gpu|openvino-cpu|onnx) [по умолч.: auto]"
),
compute_type: str | None = typer.Option(
None, "--compute-type", show_default=False,
help="Тип вычислений [по умолч.: float16 (CUDA) / int8 (OpenVINO GPU/CPU) / float32 (CPU)]"
),
threads: int = typer.Option(
0, "--threads", "-t", show_default=False, min=0,
help="Потоки CPU (0 = дефолт библиотеки; рекомендуется = число физ. ядер)"
),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Подробный вывод"),
force: bool = typer.Option(False, "--force", "-f", help="Перезаписать существующие транскрипты"),
install_menu: bool = typer.Option(False, "--install-menu", help="Установить пункт Transcribe в SendTo"),
uninstall_menu: bool = typer.Option(False, "--uninstall-menu", help="Удалить пункт Transcribe из SendTo"),
) -> None:
"""Транскрибирует аудио/видеофайлы в markdown с таймкодами.
Каскад приоритетов параметров: CLI-флаги > .transcriber.toml > device-aware дефолты.
"""
files = [] if files is None else files
if install_menu or uninstall_menu:
if install_menu and uninstall_menu:
console.print("--install-menu и --uninstall-menu несовместимы.", style="red bold")
raise SystemExit(2)
if files:
console.print("Флаги меню нельзя использовать вместе с файлами.", style="red bold")
raise SystemExit(2)
if sys.platform != "win32":
console.print("Пункт меню SendTo доступен только на Windows.", style="red bold")
raise SystemExit(1)
try:
if install_menu:
cmd_path = install_context_menu()
console.print(f"Пункт меню установлен: \"{cmd_path}\"", style="green")
else:
cmd_path = uninstall_context_menu()
if cmd_path is None:
console.print("Пункт меню не был установлен.", style="yellow")
else:
console.print(f"Пункт меню удалён: \"{cmd_path}\"", style="green")
except RuntimeError as exc:
console.print(f"Ошибка: {exc}", style="red bold")
raise SystemExit(1)
return
if not files:
console.print(
"Укажите хотя бы один файл или используйте --install-menu/--uninstall-menu.",
style="red bold",
)
raise SystemExit(2)
try:
config = load_config()
cli_values = {"model": model, "language": language, "device": device, "compute_type": compute_type}
@@ -89,9 +197,9 @@ def main(
raise SystemExit(1)
if is_batch:
_run_batch(expanded, defaults, verbose, force, ct_explicit)
_run_batch(expanded, defaults, verbose, force, ct_explicit, cpu_threads=threads)
else:
_run_single(expanded[0], defaults, output, verbose, ct_explicit)
_run_single(expanded[0], defaults, output, verbose, ct_explicit, cpu_threads=threads)
except KeyboardInterrupt:
console.print("\nПрервано пользователем.", style="yellow")
raise SystemExit(130)
@@ -129,6 +237,7 @@ def _run_single(
output: Path | None,
verbose: bool,
compute_type_explicit: bool = False,
cpu_threads: int = 0,
) -> None:
"""Пайплайн одного файла: валидация → модель → транскрипция → запись."""
start = time.monotonic()
@@ -148,6 +257,7 @@ def _run_single(
defaults["model"], resolved_device, defaults["compute_type"],
on_status=lambda msg: console.print(msg), strict_device=strict,
compute_type_explicit=compute_type_explicit,
cpu_threads=cpu_threads,
)
actual_ct = getattr(backend, "actual_compute_type", defaults["compute_type"]) or defaults["compute_type"]
console.print(
@@ -174,6 +284,7 @@ def _run_single(
on_segment=on_segment if verbose else None,
on_status=status.update,
strict_device=strict,
cpu_threads=cpu_threads,
)
result = tfr.result
@@ -211,6 +322,7 @@ def _run_single(
elapsed = time.monotonic() - start
console.print(f"Транскрипт сохранён: \"{output_path}\"", style="green")
console.print(f" Сегментов: {len(result.segments)} Время: {elapsed:.1f}с")
_print_quality_warnings(result)
def _run_batch(
@@ -219,6 +331,7 @@ def _run_batch(
verbose: bool,
force: bool,
compute_type_explicit: bool = False,
cpu_threads: int = 0,
) -> None:
"""Трёхфазный батч-пайплайн: prescan → загрузка модели → транскрипция."""
# Phase 1: Prescan — fail-fast + skip до загрузки модели (экономим ~2-5 сек)
@@ -256,6 +369,7 @@ def _run_batch(
defaults["model"], resolved_device, defaults["compute_type"],
on_status=lambda msg: console.print(msg), strict_device=strict,
compute_type_explicit=compute_type_explicit,
cpu_threads=cpu_threads,
)
if actual_device == "openvino-gpu" and defaults["model"] != "large-v3":
@@ -306,6 +420,7 @@ def _run_batch(
on_segment=on_segment if verbose else None,
on_status=status.update if not verbose else lambda msg: console.print(msg),
strict_device=strict,
cpu_threads=cpu_threads,
)
if tfr.actual_device != actual_device:
@@ -343,6 +458,7 @@ def _run_batch(
style="green",
)
processed += 1
_print_quality_warnings(result, file.name)
except KeyboardInterrupt:
raise
except Exception as exc:
+2 -1
View File
@@ -22,11 +22,12 @@ DEVICE_DEFAULTS: dict[str, dict[str, str]] = {
"openvino": {"model": "medium", "compute_type": "int8"},
"openvino-gpu": {"model": "medium", "compute_type": "int8"},
"openvino-cpu": {"model": "medium", "compute_type": "int8"},
"onnx": {"model": "gigaam-v3", "compute_type": "int8"},
}
# Одно место правды для допустимых ключей конфига
_VALID_KEYS = set(HARDCODED_DEFAULTS)
_VALID_DEVICES = {"auto", "cpu", "cuda", "openvino", "openvino-gpu", "openvino-cpu"}
_VALID_DEVICES = {"auto", "cpu", "cuda", "openvino", "openvino-gpu", "openvino-cpu", "onnx"}
def find_config_file() -> Path | None:
+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 pathlib import Path
from .quality import TAIL_GAP_WARN_S, find_repetition_blocks, tail_gap
from .types import Segment, TranscribeResult
_PAUSE_THRESHOLD_S = 2.0 # пауза между сегментами для разбиения на абзацы
@@ -63,7 +64,7 @@ def format_timestamp(seconds: float, use_hours: bool = False) -> str:
return f"{minutes:02d}:{secs:02d}.{centiseconds:02d}"
def _format_duration(seconds: float) -> str:
def format_duration(seconds: float) -> str:
"""Человекочитаемая длительность для метаданных в шапке транскрипта."""
total = int(seconds)
h = total // 3600
@@ -92,7 +93,20 @@ def format_transcript(
lines.append(f"- **Дата транскрипции**: {date.strftime('%Y-%m-%d %H:%M:%S')}")
lines.append(f"- **Модель**: {model_name}")
lines.append(f"- **Язык**: {result.language} ({language_mode})")
lines.append(f"- **Длительность**: {_format_duration(result.duration)}")
lines.append(f"- **Длительность**: {format_duration(result.duration)}")
if tail_gap(result) > TAIL_GAP_WARN_S:
last_end = result.segments[-1].end
lines.append(
f"- **Внимание**: транскрипт покрывает {format_duration(last_end)} "
f"из {format_duration(result.duration)} — возможна потеря хвоста записи"
)
for block in find_repetition_blocks(result.segments):
start = format_timestamp(block.start, use_hours=use_hours)
end = format_timestamp(block.end, use_hours=use_hours)
lines.append(
f"- **Внимание**: повторы в [{start} - {end}] ({block.count}×) "
"— возможны галлюцинации модели"
)
lines.append(f"- **Устройство**: {device_info}")
lines.append("")
lines.append("---")
+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
+9 -3
View File
@@ -22,11 +22,13 @@ def load_model(
on_status: Callable[[str], None] | None = None,
strict_device: bool = False,
compute_type_explicit: bool = False,
cpu_threads: int = 0,
) -> tuple[Any, str, Any, str]:
"""Загружает модель: ensure + create с fallback.
Возвращает (model, actual_device, backend, model_path).
compute_type_explicit: True если пользователь явно указал --compute-type.
cpu_threads: число потоков для CPU inference (0 = дефолт библиотеки).
"""
backend = get_backend(device, compute_type_explicit=compute_type_explicit)
actual_device = device
@@ -35,7 +37,7 @@ def load_model(
try:
_notify_status(on_status, f"Инициализирую модель на {device}...")
model = backend.create_model(model_path, device, compute_type)
model = backend.create_model(model_path, device, compute_type, cpu_threads=cpu_threads)
# Резолвим actual_device по реальному OpenVINO device
ov_dev = getattr(backend, "actual_ov_device", None)
if ov_dev == "GPU" and actual_device != "openvino-gpu":
@@ -55,7 +57,7 @@ def load_model(
backend = get_backend("cpu")
model_path = backend.ensure_model_available(model_name, compute_type, on_status)
_notify_status(on_status, "Инициализирую модель на cpu...")
model = backend.create_model(model_path, "cpu", compute_type)
model = backend.create_model(model_path, "cpu", compute_type, cpu_threads=cpu_threads)
else:
raise
@@ -74,6 +76,7 @@ def _transcribe_file(
on_segment: Callable[[Segment], None] | None = None,
on_status: Callable[[str], None] | None = None,
strict_device: bool = False,
cpu_threads: int = 0,
) -> TranscribeFileResult:
"""Транскрибирует один файл. При mid-stream fallback перезагружает модель."""
lang_arg = language if language and language != "auto" else None
@@ -95,7 +98,7 @@ def _transcribe_file(
backend = get_backend("cpu")
model_path = backend.ensure_model_available(model_name, compute_type, on_status)
_notify_status(on_status, "Инициализирую модель на cpu...")
model = backend.create_model(model_path, "cpu", compute_type)
model = backend.create_model(model_path, "cpu", compute_type, cpu_threads=cpu_threads)
_notify_status(on_status, "Транскрибирую...")
result = backend.transcribe(model, file_path, lang_arg, on_segment, on_status)
result.device_used = actual_device
@@ -120,16 +123,19 @@ def transcribe(
on_segment: Callable[[Segment], None] | None = None,
on_status: Callable[[str], None] | None = None,
strict_device: bool = False,
cpu_threads: int = 0,
) -> TranscribeResult:
"""High-level API: загрузка модели + транскрипция за один вызов."""
model, actual_device, backend, model_path = load_model(
model_name, device, compute_type, on_status, strict_device,
compute_type_explicit=True, # Python API — caller explicitly chose compute_type
cpu_threads=cpu_threads,
)
tfr = _transcribe_file(
model, actual_device, backend, model_path,
file_path, model_name, compute_type,
language, on_segment, on_status, strict_device,
cpu_threads=cpu_threads,
)
return tfr.result
+1 -1
View File
@@ -31,7 +31,7 @@ def test_resolve_repo_explicit_unsupported_pair_raises():
"""Явный --compute-type с несуществующей парой → ошибка."""
backend = OpenVINOBackend(compute_type_explicit=True)
with pytest.raises(ValueError, match="недоступна с compute_type='fp16'"):
backend._resolve_repo("medium", "fp16")
backend._resolve_repo("small", "fp16")
def test_resolve_repo_explicit_unknown_model_raises():
+306
View File
@@ -2,6 +2,7 @@ from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from rich.console import Console
from typer.testing import CliRunner
from local_transcriber.cli import _format_device_info, app
@@ -862,3 +863,308 @@ def test_cli_openvino_alias_resolves_to_gpu(tmp_path):
assert out.exit_code == 0
# detect_device("openvino") resolved to "openvino-gpu", load_model receives it
assert mock_load_model.call_args[0][1] == "openvino-gpu"
# === --threads ===
def test_cli_threads_passed_to_load_model(tmp_path):
"""--threads передаётся в load_model как cpu_threads."""
audio = tmp_path / "test.mp3"
audio.write_bytes(b"fake")
result = _make_result()
model = _make_model()
backend = _make_backend()
tfr = _make_tfr(result=result, model=model, backend=backend)
mock_load_model = MagicMock(return_value=(model, "cpu", backend, "/models/medium"))
with (
patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", return_value=audio),
patch("local_transcriber.cli.detect_device", return_value="cpu"),
patch("local_transcriber.cli.load_model", mock_load_model),
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch("local_transcriber.cli.write_transcript"),
):
out = runner.invoke(app, [str(audio), "--threads", "8"])
assert out.exit_code == 0
assert mock_load_model.call_args.kwargs["cpu_threads"] == 8
def test_cli_threads_default_zero(tmp_path):
"""Без --threads load_model получает cpu_threads=0."""
audio = tmp_path / "test.mp3"
audio.write_bytes(b"fake")
result = _make_result()
model = _make_model()
backend = _make_backend()
tfr = _make_tfr(result=result, model=model, backend=backend)
mock_load_model = MagicMock(return_value=(model, "cpu", backend, "/models/medium"))
with (
patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", return_value=audio),
patch("local_transcriber.cli.detect_device", return_value="cpu"),
patch("local_transcriber.cli.load_model", mock_load_model),
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch("local_transcriber.cli.write_transcript"),
):
out = runner.invoke(app, [str(audio)])
assert out.exit_code == 0
assert mock_load_model.call_args.kwargs["cpu_threads"] == 0
def test_cli_threads_negative_rejected(tmp_path):
"""--threads с отрицательным значением отклоняется typer (min=0)."""
audio = tmp_path / "test.mp3"
audio.write_bytes(b"fake")
out = runner.invoke(app, [str(audio), "--threads", "-1"])
assert out.exit_code != 0
# === SendTo context menu flags ===
def test_cli_install_menu_success(tmp_path):
cmd_path = tmp_path / "Transcribe.cmd"
with (
patch("local_transcriber.cli.install_context_menu", return_value=cmd_path) as mock_install,
patch("local_transcriber.cli.load_config") as mock_load_config,
patch("local_transcriber.cli.sys") as mock_sys,
):
mock_sys.platform = "win32"
out = runner.invoke(app, ["--install-menu"])
assert out.exit_code == 0
assert "Пункт меню установлен" in out.output
assert cmd_path.name in out.output
mock_install.assert_called_once_with()
mock_load_config.assert_not_called()
def test_cli_uninstall_menu_success(tmp_path):
cmd_path = tmp_path / "Transcribe.cmd"
with (
patch("local_transcriber.cli.uninstall_context_menu", return_value=cmd_path) as mock_uninstall,
patch("local_transcriber.cli.load_config") as mock_load_config,
patch("local_transcriber.cli.sys") as mock_sys,
):
mock_sys.platform = "win32"
out = runner.invoke(app, ["--uninstall-menu"])
assert out.exit_code == 0
assert "Пункт меню удалён" in out.output
assert cmd_path.name in out.output
mock_uninstall.assert_called_once_with()
mock_load_config.assert_not_called()
def test_cli_uninstall_menu_missing_is_success():
with (
patch("local_transcriber.cli.uninstall_context_menu", return_value=None),
patch("local_transcriber.cli.sys") as mock_sys,
):
mock_sys.platform = "win32"
out = runner.invoke(app, ["--uninstall-menu"])
assert out.exit_code == 0
assert "не был установлен" in out.output
def test_cli_menu_flags_are_mutually_exclusive():
out = runner.invoke(app, ["--install-menu", "--uninstall-menu"])
assert out.exit_code == 2
assert "несовместимы" in out.output
def test_cli_menu_flag_with_file_is_rejected(tmp_path):
audio = tmp_path / "test.mp3"
audio.write_bytes(b"fake")
out = runner.invoke(app, [str(audio), "--install-menu"])
assert out.exit_code == 2
assert "нельзя использовать вместе с файлами" in out.output
def test_cli_no_files_and_no_menu_flags_is_rejected():
out = runner.invoke(app, [])
assert out.exit_code == 2
assert "Укажите хотя бы один файл" in out.output
def test_cli_menu_flags_available_only_on_windows():
with patch("local_transcriber.cli.sys") as mock_sys:
mock_sys.platform = "linux"
out = runner.invoke(app, ["--install-menu"])
assert out.exit_code == 1
assert "только на Windows" in out.output
def test_cli_menu_runtime_error_has_no_verbose_hint():
with (
patch("local_transcriber.cli.install_context_menu", side_effect=RuntimeError("нет APPDATA")),
patch("local_transcriber.cli.sys") as mock_sys,
):
mock_sys.platform = "win32"
out = runner.invoke(app, ["--install-menu"])
assert out.exit_code == 1
assert "нет APPDATA" in out.output
assert "--verbose" not in out.output
def test_cli_tail_gap_quality_warning_single(tmp_path):
audio = tmp_path / "tail.mp3"
audio.write_bytes(b"fake")
result = _make_result(
segments=[Segment(start=0.0, end=60.0, text="Фраза")],
duration=600.0,
)
patches = _single_patches(result=result, tmp_file=audio)
with (
patches[0],
patches[1],
patches[2],
patches[3],
patches[4],
patches[5],
patch("local_transcriber.cli.console", Console(stderr=True, width=1000)),
):
out = runner.invoke(app, [str(audio)])
assert out.exit_code == 0
assert (
"Внимание: транскрипт покрывает 01:00 из 10:00 — "
"возможна потеря хвоста записи. Попробуйте другой --device."
) in out.output
def test_cli_repetition_quality_warning_single(tmp_path):
audio = tmp_path / "repeat.mp3"
audio.write_bytes(b"fake")
result = _make_result(
segments=[
Segment(start=10.0, end=11.0, text="Повторяемая фраза"),
Segment(start=11.0, end=12.0, text="повторяемая фраза"),
Segment(start=12.0, end=13.0, text="повторяемая фраза"),
Segment(start=13.0, end=14.0, text="повторяемая фраза"),
],
duration=60.0,
)
patches = _single_patches(result=result, tmp_file=audio)
with (
patches[0],
patches[1],
patches[2],
patches[3],
patches[4],
patches[5],
patch("local_transcriber.cli.console", Console(stderr=True, width=1000)),
):
out = runner.invoke(app, [str(audio)])
assert out.exit_code == 0
assert (
"Внимание: блоки повторов: [00:10.00 - 00:14.00] (4×) — "
"возможны галлюцинации модели. Попробуйте другой --device."
) in out.output
def test_cli_quality_warning_batch_includes_file_name(tmp_path):
a = tmp_path / "a.mp3"
b = tmp_path / "b.mp3"
a.write_bytes(b"fake")
b.write_bytes(b"fake")
result_warn = _make_result(
segments=[Segment(start=0.0, end=60.0, text="Фраза")],
duration=600.0,
)
result_ok = _make_result()
model = _make_model()
backend = _make_backend()
tfr_warn = _make_tfr(result=result_warn, model=model, backend=backend)
tfr_ok = _make_tfr(result=result_ok, model=model, backend=backend)
with (
patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
patch("local_transcriber.cli.detect_device", return_value="cpu"),
patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")),
patch("local_transcriber.cli._transcribe_file", side_effect=[tfr_warn, tfr_ok]),
patch("local_transcriber.cli.write_transcript"),
patch("local_transcriber.cli.console", Console(stderr=True, width=1000)),
):
out = runner.invoke(app, [str(a), str(b)])
assert out.exit_code == 0
assert (
" a.mp3: транскрипт покрывает 01:00 из 10:00 — "
"возможна потеря хвоста записи"
) in out.output
def test_cli_repetition_quality_warning_truncates_after_three_blocks(tmp_path):
audio = tmp_path / "repeat-many.mp3"
audio.write_bytes(b"fake")
def run(start, count, text):
return [
Segment(start=start + index, end=start + index + 1.0, text=text)
for index in range(count)
]
result = _make_result(
segments=[
*run(10.0, 6, "Первый повтор"),
Segment(start=18.0, end=19.0, text="Разрыв один"),
*run(20.0, 5, "Второй повтор"),
Segment(start=28.0, end=29.0, text="Разрыв два"),
*run(30.0, 4, "Третий повтор"),
Segment(start=38.0, end=39.0, text="Разрыв три"),
*run(40.0, 4, "Четвёртый повтор"),
],
duration=90.0,
)
patches = _single_patches(result=result, tmp_file=audio)
with (
patches[0],
patches[1],
patches[2],
patches[3],
patches[4],
patches[5],
patch("local_transcriber.cli.console", Console(stderr=True, width=1000)),
):
out = runner.invoke(app, [str(audio)])
assert out.exit_code == 0
assert (
"Внимание: блоки повторов: [00:10.00 - 00:16.00] (6×); "
"[00:20.00 - 00:25.00] (5×); [00:30.00 - 00:34.00] (4×) "
"(+ ещё 1) — возможны галлюцинации модели. Попробуйте другой --device."
) in out.output
def test_cli_default_result_has_no_quality_warnings(tmp_path):
audio = tmp_path / "normal.mp3"
audio.write_bytes(b"fake")
patches = _single_patches(tmp_file=audio)
with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5]:
out = runner.invoke(app, [str(audio)])
assert out.exit_code == 0
assert "потеря хвоста" not in out.output
assert "галлюцинации" not in out.output
+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"
write_transcript("# Test content\n", out)
assert out.read_text(encoding="utf-8") == "# Test content\n"
def test_format_transcript_tail_gap_warning():
result = TranscribeResult(
segments=[Segment(start=0.0, end=60.0, text=" Фраза.")],
language="ru",
language_probability=0.95,
duration=600.0,
device_used="cpu",
)
content = format_transcript(
result,
source_filename="tail.mp3",
model_name="medium",
device_info="CPU",
language_mode="forced",
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
)
assert "возможна потеря хвоста" in content
assert "транскрипт покрывает 01:00 из 10:00" in content
def test_format_transcript_no_tail_gap_warning_for_small_gap():
result = TranscribeResult(
segments=[Segment(start=0.0, end=60.0, text=" Фраза.")],
language="ru",
language_probability=0.95,
duration=179.99,
device_used="cpu",
)
content = format_transcript(
result,
source_filename="ok.mp3",
model_name="medium",
device_info="CPU",
language_mode="forced",
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
)
assert "потеря хвоста" not in content
def test_format_transcript_no_tail_gap_warning_for_exact_threshold():
result = TranscribeResult(
segments=[Segment(start=0.0, end=60.0, text=" Фраза.")],
language="ru",
language_probability=0.95,
duration=180.0,
device_used="cpu",
)
content = format_transcript(
result,
source_filename="ok.mp3",
model_name="medium",
device_info="CPU",
language_mode="forced",
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
)
assert "потеря хвоста" not in content
def test_format_transcript_repetition_warning():
result = TranscribeResult(
segments=[
Segment(start=10.0, end=11.0, text=" Повторяемая фраза."),
Segment(start=11.0, end=12.0, text=" повторяемая фраза"),
Segment(start=12.0, end=13.0, text=" «Повторяемая фраза»"),
Segment(start=13.0, end=14.0, text=" повторяемая фраза…"),
],
language="ru",
language_probability=0.95,
duration=60.0,
device_used="cpu",
)
content = format_transcript(
result,
source_filename="repeat.mp3",
model_name="medium",
device_info="CPU",
language_mode="forced",
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
)
assert "повторы в [00:10.00 - 00:14.00] (4×)" in content
assert "возможны галлюцинации" in content
def test_format_transcript_repetition_warning_uses_hours():
result = TranscribeResult(
segments=[
Segment(start=3600.0, end=3601.0, text=" Повтор."),
Segment(start=3601.0, end=3602.0, text=" повтор"),
Segment(start=3602.0, end=3603.0, text=" повтор"),
Segment(start=3603.0, end=3604.0, text=" повтор"),
],
language="ru",
language_probability=0.95,
duration=3700.0,
device_used="cpu",
)
content = format_transcript(
result,
source_filename="long-repeat.mp3",
model_name="medium",
device_info="CPU",
language_mode="forced",
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
)
assert "повторы в [01:00:00.00 - 01:00:04.00] (4×)" in content
def test_format_transcript_without_anomalies_has_no_warning_lines():
result = TranscribeResult(
segments=[Segment(start=0.0, end=60.0, text=" Обычная запись.")],
language="ru",
language_probability=0.95,
duration=120.0,
device_used="cpu",
)
content = format_transcript(
result,
source_filename="ok.mp3",
model_name="medium",
device_info="CPU",
language_mode="forced",
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
)
assert "Внимание" not in content
+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, "Повтор")]) == []
+58
View File
@@ -321,6 +321,34 @@ def test_load_model_returns_backend_and_path(mock_get_backend):
assert actual_device == "cpu"
@patch("local_transcriber.transcriber.get_backend")
def test_load_model_passes_cpu_threads_to_backend(mock_get_backend):
backend = _make_backend(model_path="/mock/model/path")
mock_get_backend.return_value = backend
load_model("tiny", "cpu", "int8", cpu_threads=8)
assert backend.create_model.call_args.kwargs["cpu_threads"] == 8
@patch("local_transcriber.transcriber.get_backend")
def test_load_model_fallback_preserves_cpu_threads(mock_get_backend):
cuda_backend = _make_backend(create_model_error=RuntimeError("CUDA out of memory"))
cpu_model = MagicMock()
cpu_backend = _make_backend(model=cpu_model, model_path="/mock/cpu/model")
def backend_for_device(device, **kwargs):
return cuda_backend if device == "cuda" else cpu_backend
mock_get_backend.side_effect = backend_for_device
with pytest.warns(UserWarning, match="Переключение на CPU"):
load_model("tiny", "cuda", "int8", cpu_threads=6)
assert cuda_backend.create_model.call_args.kwargs["cpu_threads"] == 6
assert cpu_backend.create_model.call_args.kwargs["cpu_threads"] == 6
# === _transcribe_file() tests ===
@@ -527,6 +555,36 @@ def test_transcribe_file_openvino_gpu_midstream_fallback(mock_get_backend):
assert tfr.model_path == "/mock/cpu/model"
@patch("local_transcriber.transcriber.get_backend")
def test_transcribe_file_midstream_fallback_preserves_cpu_threads(mock_get_backend):
ov_backend = _make_backend(
transcribe_error=RuntimeError("OpenVINO inference error"),
)
cpu_backend = _make_backend(
transcribe_result=_make_result(count=2, device_used="cpu"),
model_path="/mock/cpu/model",
)
def backend_for_device(device, **kwargs):
return ov_backend if device.startswith("openvino") else cpu_backend
mock_get_backend.side_effect = backend_for_device
with pytest.warns(UserWarning, match="Переключение на CPU"):
_transcribe_file(
model=MagicMock(),
actual_device="openvino-gpu",
backend=ov_backend,
model_path="/mock/ov/model",
file_path=Path("test.mp3"),
model_name="medium",
compute_type="fp16",
cpu_threads=6,
)
assert cpu_backend.create_model.call_args.kwargs["cpu_threads"] == 6
@patch("local_transcriber.transcriber.get_backend")
def test_openvino_gpu_strict_device_no_fallback(mock_get_backend):
"""strict_device=True + OpenVINO GPU ошибка → raise."""
Generated
+24
View File
@@ -301,6 +301,7 @@ source = { editable = "." }
dependencies = [
{ name = "faster-whisper" },
{ name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "onnx-asr", extra = ["cpu", "hub"] },
{ name = "openvino-genai", marker = "(platform_machine == 'AMD64' and sys_platform != 'darwin') or (platform_machine == 'x86_64' and sys_platform != 'darwin')" },
{ name = "rich" },
{ name = "socksio" },
@@ -317,6 +318,7 @@ dev = [
requires-dist = [
{ name = "faster-whisper", specifier = ">=1.2.1" },
{ name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=12.4" },
{ name = "onnx-asr", extras = ["cpu", "hub"], specifier = ">=0.11.0,<0.12.0" },
{ name = "openvino-genai", marker = "(platform_machine == 'AMD64' and sys_platform != 'darwin') or (platform_machine == 'x86_64' and sys_platform != 'darwin')", specifier = ">=2025.0" },
{ name = "rich" },
{ name = "socksio", specifier = ">=1.0.0" },
@@ -512,6 +514,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/77/3c/aa88abe01f3be3d1f8f787d1d33dc83e76fec05945f9a28fbb41cfb99cd5/nvidia_cublas_cu12-12.9.1.4-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:453611eb21a7c1f2c2156ed9f3a45b691deda0440ec550860290dc901af5b4c2", size = 581242350, upload-time = "2025-06-05T20:04:51.979Z" },
]
[[package]]
name = "onnx-asr"
version = "0.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/78/f6/b154881761a593312f509522f99542acffa2516f7a1df6ddf5660ad4a162/onnx_asr-0.11.0.tar.gz", hash = "sha256:57ad8d9571dc17db95f0daf9ba432b9472383de320c610735850e56b5375a37d", size = 43665, upload-time = "2026-03-23T02:30:57.349Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/04/bdffd682cc38b43144b6528186c80451f219a05e3fd0eb331a548f455b9a/onnx_asr-0.11.0-py3-none-any.whl", hash = "sha256:142d8b3ce7716684992826a269304f5ce9cf1c0fe704b751358e223f45d2a5cf", size = 138349, upload-time = "2026-03-23T02:30:58.566Z" },
]
[package.optional-dependencies]
cpu = [
{ name = "onnxruntime" },
]
hub = [
{ name = "huggingface-hub" },
]
[[package]]
name = "onnxruntime"
version = "1.24.3"