Compare commits
26
Commits
0b5703a0e3
...
5a7fc0d613
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a7fc0d613 | ||
|
|
b1dfd9dcca | ||
|
|
650e7580e5 | ||
|
|
df204e5ae4 | ||
|
|
9307486ceb | ||
|
|
f18ad6ea2a | ||
|
|
966efc3bfe | ||
|
|
e41600510b | ||
|
|
0059e28f77 | ||
|
|
1c779be139 | ||
|
|
9cfa437b33 | ||
|
|
0c3a67c4ea | ||
|
|
3b7eb603ae | ||
|
|
5990d85a58 | ||
|
|
503f2a3732 | ||
|
|
854bf55145 | ||
|
|
53142fd341 | ||
|
|
b1cbdc3e0b | ||
|
|
7d39ab908b | ||
|
|
79dbd170ce | ||
|
|
9e04dc8c25 | ||
|
|
d9c9aefdb3 | ||
|
|
f25a546754 | ||
|
|
a3d0213cb3 | ||
|
|
b5c1da24ce | ||
|
|
82596187a4 |
@@ -4,3 +4,6 @@ __pycache__/
|
||||
.mypy_cache/
|
||||
dist/
|
||||
*.pyc
|
||||
.codex
|
||||
.qwen/
|
||||
.scratch/
|
||||
@@ -0,0 +1,77 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project
|
||||
|
||||
Local audio/video transcription CLI — no cloud, no API keys. Outputs markdown with timestamps.
|
||||
|
||||
**Language conventions**: code identifiers in English; docstrings, comments, UI strings, and commit messages in Russian. Style is ruff-compatible. Commits follow [Conventional Commits](https://www.conventionalcommits.org/).
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
uv sync # install dependencies
|
||||
uv run transcribe meeting.mp4 # run CLI
|
||||
uv run pytest # run all tests
|
||||
uv run pytest tests/test_cli.py # run one test file
|
||||
uv run pytest -k test_name # run single test by name
|
||||
uv run pytest -v # verbose output
|
||||
```
|
||||
|
||||
Package manager is **uv** (not pip). Build backend is hatchling.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
CLI (cli.py)
|
||||
→ config.py cascade: CLI arg → .transcriber.toml → device-aware default → hardcoded
|
||||
→ utils.py detect_device(), validate files, expand globs (Windows workaround)
|
||||
→ context_menu.py Windows SendTo: Transcribe.cmd install/uninstall (--install-menu / --uninstall-menu)
|
||||
→ transcriber.py load_model() → get_backend(device) → ensure_model_available → create_model
|
||||
_transcribe_file() with mid-stream CUDA→CPU fallback
|
||||
→ formatter.py segments → markdown with timestamps, paragraph grouping (>2s pause or >60s)
|
||||
```
|
||||
|
||||
### Backend system (`src/local_transcriber/backends/`)
|
||||
|
||||
Three backends implement the `Backend` Protocol (structural typing, no inheritance required):
|
||||
|
||||
| Backend | Module | Devices | Library |
|
||||
|---------|--------|---------|---------|
|
||||
| FasterWhisper | `faster_whisper.py` | `cpu`, `cuda` | `faster_whisper` (CTranslate2) |
|
||||
| OpenVINO | `openvino.py` | `openvino`, `openvino-gpu`, `openvino-cpu` | `openvino_genai` |
|
||||
| OnnxAsr | `onnx_asr.py` | `onnx` | `onnx_asr` (onnxruntime) |
|
||||
|
||||
`get_backend(device)` in `backends/__init__.py` maps device string to backend with lazy imports.
|
||||
|
||||
### Key design decisions
|
||||
|
||||
- **Two-level fallback**: GPU→CPU at model load time AND mid-stream during transcription (GPU visible via nvidia-smi but insufficient VRAM).
|
||||
- **CUDA bootstrap** (`_cuda_bootstrap.py`): preloads `libcublas.so.12` via `ctypes.CDLL(RTLD_GLOBAL)` before importing ctranslate2, because pip's `nvidia-cublas-cu12` installs to a non-standard path and `LD_LIBRARY_PATH` can't be changed at runtime (glibc caches it).
|
||||
- **Batch mode**: 3-phase pipeline (prescan → load model once → transcribe all). `TranscribeFileResult` carries updated model/backend/device state between files.
|
||||
- **Device-aware defaults**: `compute_type` and `model` vary by device (float16 for CUDA, int8 for OpenVINO, float32 for CPU). Defined in `config.py` `DEVICE_DEFAULTS`.
|
||||
- **OpenVINO uses pre-quantized models** — `compute_type` selects which HF repo to download, not a runtime parameter.
|
||||
|
||||
## Testing
|
||||
|
||||
All tests mock backends — no real model downloads or transcription. Key test patterns:
|
||||
|
||||
- CLI tests: `typer.testing.CliRunner` + mocks for `load_config`, `detect_device`, `load_model`, `_transcribe_file`, `write_transcript`
|
||||
- `_single_patches()` — helper assembling standard happy-path mock set
|
||||
- `_make_result()` / `_make_tfr()` — factories for test data
|
||||
|
||||
## Common tasks
|
||||
|
||||
- **New CLI option**: add `typer.Option` in `cli.py:main()` → add key to `HARDCODED_DEFAULTS` in `config.py` → write test
|
||||
- **New audio/video format**: add extension to `SUPPORTED_EXTENSIONS` in `utils.py`
|
||||
- **New backend**: implement `Backend` protocol → add device mapping in `backends/__init__.py` → add device-aware defaults in `config.py`
|
||||
- **Change output format**: edit `format_transcript()` in `formatter.py`
|
||||
|
||||
## Project docs
|
||||
|
||||
- `docs/PRD.md` — product requirements and scope
|
||||
- `docs/backlog.md` — future experiments and ideas
|
||||
- `docs/gpu.md` — GPU benchmarks, platform compatibility details
|
||||
- `docs/adr/` — architecture decision records (CUDA bootstrap, batch mode, pluggable backends, compute-type defaults, ONNX-ASR evaluation)
|
||||
|
||||
@@ -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,8 @@ 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` | — | Подробный вывод |
|
||||
@@ -158,6 +190,7 @@ transcribe *.mp4 --force
|
||||
| CPU | ✅ | ✅ | ✅ |
|
||||
| OpenVINO (x86 CPU) | ✅ авто | — | ✅ авто |
|
||||
| OpenVINO (Intel GPU) | ✅ авто | — | ✅ авто |
|
||||
| ONNX (CPU) | ✅ явно | ✅ явно | ✅ явно |
|
||||
| GPU (NVIDIA) | ✅ авто | — | ✅ (нужен CUDA 12) |
|
||||
|
||||
<details>
|
||||
@@ -210,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
|
||||
|
||||
@@ -222,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>
|
||||
@@ -235,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>
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# ADR-006: onnx-asr бэкенд — GigaAM v3 как CPU-default для русских встреч
|
||||
|
||||
**Статус**: Принято
|
||||
**Дата**: 2026-04-25
|
||||
|
||||
## Контекст
|
||||
|
||||
Целевая аудитория `local-transcriber` — пользователи с Intel iGPU / CPU, без дискретного NVIDIA GPU.
|
||||
Существующие CPU-бэкенды (faster-whisper, OpenVINO) дают 0.5-6x RTF для средних моделей — транскрипция часовой записи занимает 10-120 минут.
|
||||
|
||||
[onnx-asr](https://github.com/istupakov/onnx-asr) — легковесная обёртка (onnxruntime + numpy) над ONNX-моделями Parakeet, GigaAM, FastConformer и Canary. Заявляет 30-90x RTF на CPU при сравнимом с Whisper качестве для русского языка.
|
||||
|
||||
[ADR-005](005-parakeet-evaluation.md) ранее отклонил Parakeet TDT 0.6B v3 для целевого use case на двух 15-минутных файлах с тихим микрофоном менти и плотной IT-терминологией. ADR-006 повторяет эксперимент на новом наборе файлов (22-81 мин, разной громкости), добавляет GigaAM v3 как кандидата для русской речи и проверяет три класса проблем Parakeet из ADR-005 на актуальном материале.
|
||||
|
||||
## Эксперимент
|
||||
|
||||
Три реальных русскоязычных записи установочных встреч (формат ментор↔менти, mp4, 22-81 мин), ноутбук с Intel i7-11800H (CPU-only). Сравнивались три CPU-бэкенда:
|
||||
|
||||
- **gigaam-v3** (onnx-asr GigaAM v3 CTC, int8, monolingual ru, без пунктуации)
|
||||
- **parakeet-v3** (onnx-asr Parakeet TDT 0.6B v3, int8, multilingual)
|
||||
- **ov-medium** (OpenVINO Whisper medium int8) — baseline
|
||||
|
||||
Качественная оценка проведена независимым agent-judge'ем по методологии ADR-005 (4 критерия: completeness / term accuracy / fluency / summary utility, шкала 1-5). Транскрипты сохранены в `/mnt/c/ddmitry/Videos/OBS/<basename>.{onnx-gigaam,parakeet-v3,ov-medium}.md` и доступны для верификации.
|
||||
|
||||
### Скорость
|
||||
|
||||
| Файл | Длит. | gigaam-v3 | parakeet-v3 --ru | OpenVINO medium |
|
||||
|---|---|---|---|---|
|
||||
| 10-59-59 | 22:26 | 81с (16.6×) | 116с (11.6×) | 265с (5.1×) |
|
||||
| Vasya | 45:51 | 95с (29×) | 138с (20×) | 455с (6×) |
|
||||
| 12-02-37 | 1:20:44 | 170с (28.5×) | 251с (19.3×) | 779с (6.2×) |
|
||||
|
||||
GigaAM в 3-5× быстрее OpenVINO medium, Parakeet в 2.5-3.5× быстрее.
|
||||
|
||||
### Качество (agent-judge, 1-5)
|
||||
|
||||
| Файл | Backend | Completeness | Term accuracy | Fluency | Summary utility |
|
||||
|------|---------|:-:|:-:|:-:|:-:|
|
||||
| 10-59-59 | **gigaam** | **5** | **4** | **4** | **4** |
|
||||
| 10-59-59 | parakeet | 4 | 2 | 2 | 2 |
|
||||
| 10-59-59 | ov-medium | 3 | 4 | 4 | 2 |
|
||||
| Vasya | **gigaam** | 3 | **4** | **4** | **4** |
|
||||
| Vasya | parakeet | 2 | 2 | 2 | 2 |
|
||||
| Vasya | ov-medium | 2 | 3 | 2 | 2 |
|
||||
| 12-02-37 | **gigaam** | 4 | 3 | 3 | **4** |
|
||||
| 12-02-37 | parakeet | 3 | 2 | 1 | 1 |
|
||||
| 12-02-37 | ov-medium | 2 | 3 | 2 | 1 |
|
||||
|
||||
GigaAM — единственный backend, дающий summary utility 4/5 на всех трёх файлах. Parakeet и ov-medium систематически уступают по разным причинам (см. ниже).
|
||||
|
||||
### Класс ошибок: Parakeet — три проблемы из ADR-005 воспроизведены
|
||||
|
||||
Все три класса систематических ошибок Parakeet, описанные в [ADR-005](005-parakeet-evaluation.md), воспроизводятся на новом наборе файлов:
|
||||
|
||||
**1. Mm-hmm/Yeah-редукция тихих реплик менти.** Массово на всех трёх файлах:
|
||||
- Vasya `[04:56-09:33]` блок из ~10 реплик: `Mm-hmm. Mm-hmm. Mm. That's nice. Mm-hmm. Mm-hmm.` — полностью утеряны ответы менти на вопросы ментора.
|
||||
- 10-59-59 `[19:03]` `Yeah. Иногда лучше дышали в облаке`.
|
||||
- 12-02-37 `[00:00:01]` `I mean.` вместо «не пони…».
|
||||
|
||||
**2. Вставки иностранных языков посреди русского.** На этом наборе ещё агрессивнее, чем в ADR-005 (там был только польский):
|
||||
- 10-59-59 `[00:08]` `Secondo, Alice. The mutual microphone.` — итальянский+английский для «секунду, Алиса, замьючен микрофон».
|
||||
- 10-59-59 `[22:02]` `Ah si va sur. Well.` — испано-французская смесь в финальном прощании.
|
||||
- Vasya `[27:31]` `Mas o żegnienie.` — польский в полностью русской встрече.
|
||||
- 12-02-37 `[10:38]` `Запроси к Każdemu Actually, таблица классная` — русско-польско-английский в одной фразе.
|
||||
- 12-02-37 `[01:03:23]` `No już je wsie.` — польский («ну уже всё»).
|
||||
|
||||
**3. Искажение IT-терминов и имён компаний:**
|
||||
- 10-59-59 `[02:21]` `не Аринадата и не Терринте игра` вместо «Аренадата и Тере-Интегра» (имена работодателей).
|
||||
- 10-59-59 `[01:23]` `Запромбанке` (с unk-токенами) вместо «Газпромбанк».
|
||||
- 12-02-37 `[02:35]` `Basic space clear cause` вместо «база данных кликхаус».
|
||||
- 12-02-37 `[06:12]` `Поскре это не колочный, чтобы это греплан. Ловочная.` — Postgres/Greenplum/«колоночная» искажены до неразборчивости.
|
||||
- 12-02-37 `[16:53]` `своеобресть` вместо «Wildberries» — целевой работодатель в задаче, имя потеряно.
|
||||
|
||||
### Класс ошибок: Whisper medium — галлюцинации на длинных файлах с тихими фрагментами
|
||||
|
||||
Не описано в ADR-005 (там были 15-минутные отрывки) — обнаружено только на длинных файлах:
|
||||
|
||||
- 12-02-37 `[01:03:43-01:20:14]` — **17 минут хвоста встречи** забиты галлюцинированными повторами: `«Вместе с вами мы решим, как мы будем работать с вами»`, `«Это не то, чтобы не было»`, `«Выбор? Нет. Выбор? Нет.»`. Бытовая часть встречи целиком потеряна.
|
||||
- 12-02-37 `[19:54-20:49]` — 11 повторов `«И вот, как я вам рассказываю, это очень интересно»` вместо реального решения SQL-задачи.
|
||||
- Vasya `[10:08-11:59]` — ~6 повторов `«Но если вы хотите, чтобы мы не разговаривали, то вы можете.»` (~2 минуты галлюцинации).
|
||||
- Vasya `[36:00-36:30]` — 14 повторов `«Ага. Ага.»` (loop).
|
||||
- Vasya `[45:51]` — `«Субтитры сделаны с помощью СМС, аппарата — Лариса.»` — классический Whisper-артефакт «титров».
|
||||
- 10-59-59 `[10:56-11:40]` — строка из ~1000 символов `«ааааа…»` — галлюцинация на тихом фрагменте, проглатывает 30 секунд аудио.
|
||||
- 12-02-37 `[01:18:47]` — приписан несуществующий человек `«Валерий Сюткин»`.
|
||||
|
||||
Это критичный класс ошибок: текст выглядит правдоподобно, и читатель конспекта не отличит галлюцинацию от реального содержания без возврата к аудио. Хуже потери — потому что вводит в заблуждение.
|
||||
|
||||
### Класс ошибок: GigaAM — локальные искажения латиницы и имён
|
||||
|
||||
GigaAM monolingual ru, латиницу не выдаёт. На транскрипте:
|
||||
- `«эскель»`/`«эсквель»` вместо `SQL` (везде кириллицей).
|
||||
- `«гитам ардауна»` вместо `git и markdown` (Vasya `[14:14]`).
|
||||
- `«арендата»`/`«арендат»`/`«арендода»` для «Аренадата» (10-59-59 `[02:21]`) — три разных варианта одного имени.
|
||||
- `«дв один»` вместо `DEV1` (12-02-37 `[00:50:56]`).
|
||||
- `«яндекс тим под яндекс тим»` для «Яндекс ТимКод» (12-02-37 `[00:14:15]`).
|
||||
|
||||
Mm-hmm-редукция и иностранные вставки **не обнаружены**: monolingual архитектура исключает language-confusion, тихие реплики менти остаются как русские «угу/да/ну».
|
||||
|
||||
Эти ошибки локальны, предсказуемы и легко чинятся LLM-этапом нормализации без знания исходного аудио (восстановить SQL, Greenplum, ClickHouse, имена компаний из контекста).
|
||||
|
||||
## Решение
|
||||
|
||||
**Принять onnx-asr как экспериментальный бэкенд с явным `--device onnx`. GigaAM v3 — рекомендуемая модель для русских встреч на CPU.**
|
||||
|
||||
Бэкенд **не в auto-detect** — только при явном указании пользователем (политика experimental backend, как для openvino).
|
||||
|
||||
Модели:
|
||||
- **`gigaam-v3`** — рекомендуемая для русских встреч на CPU. 17-29× RTF, summary utility 4/5 на всех протестированных файлах. Без пунктуации, без латиницы; ошибки локальны, чинятся LLM-нормализацией.
|
||||
- **`parakeet-v3`** — multilingual (25 языков), формально доступен. **Не рекомендуется для русских встреч**: Mm-hmm-редукция и иноязычные вставки воспроизводятся систематически (см. выше). Уместен только для англоязычного контента.
|
||||
|
||||
Обе модели в int8-квантизации (~300 MB).
|
||||
|
||||
## Последствия
|
||||
|
||||
- Пользователи CPU-only с русскоязычным контентом получают 3-5× ускорение по сравнению с OpenVINO medium **при превосходящем качестве** (4/5 vs 1-2/5 summary utility на длинных файлах).
|
||||
- Whisper medium (`--device openvino-cpu`) **остаётся допустимым** для коротких (≤30 мин) встреч с равномерной громкостью; на длинных файлах с тихими участками он галлюцинирует целыми блоками — этот риск зафиксирован, но решение не выводит OpenVINO из списка дефолтов (часть пользователей всё ещё нуждается в пунктуации, и для коротких файлов галлюцинации не воспроизводятся).
|
||||
- Parakeet-v3 формально доступен, но в README рекомендуется только для англоязычного контента — для русского явно не годится.
|
||||
- GPU faster-whisper large-v3 остаётся эталоном по качеству (для пользователей с NVIDIA GPU).
|
||||
- Пост-процессинг GigaAM-транскрипта LLM-этапом нормализации (восстановление латинских терминов и имён компаний) — рекомендуемая практика для финального конспекта.
|
||||
|
||||
## Открытые вопросы / следующие шаги
|
||||
|
||||
- **Galлюцинации Whisper medium на длинных файлах** — отдельный продуктовый риск, требующий собственного исследования. Возможно, имеет смысл ограничить максимальную длину чанка для openvino-medium, или дать предупреждение пользователю.
|
||||
- **GigaAM v3 RNN-T** (вариант `gigaam-v3-rnnt` вместо `gigaam-v3-ctc`) — заявлен как немного качественнее CTC, не тестировался. Может закрыть часть GigaAM-ошибок на латинице.
|
||||
- **Canary** (`nemo-canary-1b-v2`) — тяжелее, но multilingual + пунктуация. Кандидат на «лучшее качество за разумную скорость» для тех, кому важна пунктуация.
|
||||
- **Auto-detect onnx**: после стабилизации в production-использовании (несколько недель) — рассмотреть включение в auto-detect как первый CPU-бэкенд (ниже CUDA, выше OpenVINO).
|
||||
|
||||
## Отклонённые альтернативы
|
||||
|
||||
| Альтернатива | Почему отклонена |
|
||||
|---|---|
|
||||
| NeMo Parakeet напрямую (без onnx-asr) | Требует PyTorch + CUDA, Python ≥ 3.12, ~2 GB зависимостей — слишком тяжело для CLI |
|
||||
| Замена faster-whisper на onnx-asr | faster-whisper поддерживает 99+ языков и пунктуацию, остаётся лучшим GPU-бэкендом |
|
||||
| GigaAM как auto-detect default | Экспериментальный бэкенд, политика — не сюрпризить существующих пользователей; включение в auto-detect — после периода стабилизации |
|
||||
| Parakeet-v3 как multilingual default | Воспроизведённые проблемы из ADR-005 (Mm-hmm-редукция, иноязычные вставки) делают его непригодным для русского; для других языков не валидировано в этом эксперименте |
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
# Backlog — будущие эксперименты и направления
|
||||
|
||||
Список открытых направлений, которые имеют смысл, но не реализованы. Каждый пункт содержит обоснование и ссылку на источник (ADR / статья), чтобы при возврате не пришлось воспроизводить контекст с нуля.
|
||||
|
||||
Когда направление становится в работу — переносится в spec/план или соответствующий ADR. Когда отклоняется — остаётся в backlog с пометкой «отклонено» и причиной (для истории решений).
|
||||
|
||||
---
|
||||
|
||||
## ASR-бэкенды и модели
|
||||
|
||||
### GigaAM v3 RNN-T — потенциальная замена CTC-дефолта для `--device onnx`
|
||||
|
||||
**Что:** Прогнать `gigaam-v3-rnnt` (или `v3_e2e_rnnt` если доступен через onnx-asr) по той же методологии, что и в [ADR-006](adr/006-onnx-asr-backend.md): 3 файла × agent-judge × 4 критерия.
|
||||
|
||||
**Почему интересно:**
|
||||
|
||||
- **WER 2.6% vs 13.2%** для CTC на сложных текстах — в 5 раз ниже на разговорной речи и доменной лексике (источник: SberDevices / Хабр-публикация GigaAM-v3).
|
||||
- **Контекстный декодер** — структурно решает основную проблему GigaAM-CTC из ADR-006: кириллизация латиницы и искажения имён компаний (`Запромбанк` → `Газпромбанк`, `яндекс тим под яндекс тим` → `Яндекс ТимКод`). RNN-T видит контекст уже сгенерированных токенов и может «дотянуть» имена.
|
||||
- **`v3_e2e_rnnt` с пунктуацией и нормализацией** — закрывает главное ограничение GigaAM-CTC, ради которого в README сейчас стоит fallback на `openvino-cpu medium` (с задокументированными в ADR-006 галлюцинациями на длинных файлах).
|
||||
- **70:30 vs Whisper-large-v3** — GigaAM-v3 (CTC и RNN-T) выигрывает у `large-v3` по LLM-as-Judge (Gemini 2.5 Pro). Если переносится на наш use case — RNN-T на CPU становится сильнее GPU faster-whisper large-v3.
|
||||
- **30% лучше на «новых доменах»** (callcenter-like речь, нестандартные характеристики) — это и есть домен установочных встреч.
|
||||
|
||||
**Tradeoff:**
|
||||
|
||||
- Скорость ниже CTC (RNN-T декодинг последовательный). Реалистичная оценка: 10-15× RTF на CPU вместо 17-29× у CTC. Всё ещё в 1.5-2× быстрее Whisper medium.
|
||||
- Размер модели больше (~500 MB int8 против ~300 MB у CTC) — оценка, нужна верификация.
|
||||
|
||||
**Если подтвердится бенчмарком:**
|
||||
|
||||
- Дефолт в `--device onnx` меняется с `gigaam-v3-ctc` на `gigaam-v3-rnnt`.
|
||||
- `openvino-cpu medium` уходит из рекомендаций для русского.
|
||||
- Формулировка «GPU faster-whisper large-v3 — эталон» в README может стать неточной.
|
||||
- Пишется ADR-007 с переоценкой дефолта.
|
||||
|
||||
**Уточнить перед запуском:** какой именно вариант RNN-T доступен в onnx-asr (`gigaam-v3-rnnt` без пунктуации vs `v3_e2e_rnnt` с пунктуацией). Проверить через `huggingface_hub` listing для `istupakov/gigaam-v3-onnx`.
|
||||
|
||||
---
|
||||
|
||||
### Canary 1B — multilingual + пунктуация на CPU
|
||||
|
||||
**Что:** Протестировать `nemo-canary-1b-v2` через onnx-asr на тех же 3 файлах.
|
||||
|
||||
**Почему:** Multilingual + пунктуация в одной модели. Кандидат на «лучшее качество за разумную скорость» для пользователей, которым нужны и не-русский контент, и пунктуация одновременно. Упомянут в [ADR-006](adr/006-onnx-asr-backend.md#открытые-вопросы--следующие-шаги).
|
||||
|
||||
**Tradeoff:** Тяжелее GigaAM (~1 GB vs ~300 MB), скорость на CPU ожидаемо ниже. Если RNN-T закроет потребность в пунктуации — Canary становится менее приоритетным.
|
||||
|
||||
---
|
||||
|
||||
## Качество и устойчивость
|
||||
|
||||
### Whisper medium галлюцинации на длинных файлах с тихими фрагментами
|
||||
|
||||
**Что:** Воспроизвести и зафиксировать класс ошибок: `12-02-37` теряет 17 минут хвоста, `Vasya` имеет 5-минутные блоки повторов, появляются несуществующие имена (`Валерий Сюткин`). Источник: agent-judge в [ADR-006](adr/006-onnx-asr-backend.md#класс-ошибок-whisper-medium--галлюцинации-на-длинных-файлах-с-тихими-фрагментами).
|
||||
|
||||
**Возможные направления:**
|
||||
|
||||
- Ограничить длину чанка для openvino-medium (chunk_length параметр в WhisperPipeline).
|
||||
- Внедрить `compression_ratio_threshold` / `log_prob_threshold` фильтры через переписывание pipeline (как у CTranslate2). Уже частично описано в [docs/gpu.md «Качественный pipeline для OpenVINO»](gpu.md#качественный-pipeline-для-openvino).
|
||||
- Предупреждать пользователя при `--device openvino-cpu` для файлов >30 мин.
|
||||
|
||||
**Приоритет:** средний — пока есть `gigaam-v3` как альтернатива для русского. Критично, если openvino остаётся единственным вариантом для пунктуации (отпадает после теста RNN-T).
|
||||
|
||||
---
|
||||
|
||||
### Интеллектуальное чанкование длинных файлов по паузам
|
||||
|
||||
**Что:** Резать длинные файлы на чанки (~90 с) не по фиксированной сетке, а по ближайшей тишине: `ffmpeg -af silencedetect=noise=-40dB:d=0.5` → парсинг stderr → выбор точки разреза в окне ±30 с вокруг целевой границы (с минимальным зазором между разрезами, чтобы не получить нулевые чанки).
|
||||
|
||||
**Почему:** Разрез посреди слова/фразы портит распознавание на границах чанков; разрез по паузе — нет. Потенциально смягчает класс ошибок Whisper medium на длинных файлах (потеря хвоста, блоки повторов — см. пункт выше): короткие чанки не дают декодеру «уплыть».
|
||||
|
||||
**Источник:** референсная реализация Parakeet-сервера (Flask, OpenAI-совместимый API), лежавшая в репо как `app.py` в период эксперимента ADR-005/006 (апрель 2026); удалена при чистке 2026-07-09 — рабочие константы: порог -40dB, мин. тишина 0.5 с, окно поиска 30 с, мин. зазор 5 с.
|
||||
|
||||
**Tradeoff:** дополнительный проход ffmpeg по всему файлу (silencedetect) перед транскрипцией; для часового файла — десятки секунд.
|
||||
|
||||
---
|
||||
|
||||
### Качественный pipeline для OpenVINO (temperature fallback + фильтры)
|
||||
|
||||
**Что:** Реализовать temperature fallback, compression_ratio и log_prob фильтры поверх OpenVINO GenAI WhisperPipeline. Эвристики — логика на Python (~50-100 строк), не зависящая от inference engine.
|
||||
|
||||
**Почему:** Дать Intel Arc / AMD GPU и AMD CPU то же качество, что сейчас есть только у CUDA-пользователей через CTranslate2. Полностью описано в [docs/gpu.md](gpu.md#качественный-pipeline-для-openvino).
|
||||
|
||||
---
|
||||
|
||||
## Структура транскрипта (конспекты и MoM)
|
||||
|
||||
Источник раздела: внешнее сравнение локального транскрипта (medium,
|
||||
openvino-cpu, запись 25:59) с облачным сервисом Hypescribe, критерий —
|
||||
пригодность как сырья для конспекта и протокола встречи (GPT-ревью,
|
||||
2026-07-10). Итог: по смыслу локальная модель почти равна облаку
|
||||
(6.5/10 против 7/10), главный разрыв — **не качество распознавания,
|
||||
а структура**: разделение говорящих (2/10 против 8/10) и нарезка на
|
||||
реплики. Приоритеты ревьюера: 1) смысл, 2) спикеры, 3) техтермины,
|
||||
4) разбивка на фразы, 5) таймкоды. Вывод: локальная диаризация +
|
||||
словарь терминов закрывают потребность в облачном сервисе для
|
||||
внутренних встреч.
|
||||
|
||||
Сознательно вне ядра CLI (максимум — рецепт в README): второй проход
|
||||
LLM для чистки текста, сопоставление Speaker N с именами — это работа
|
||||
поверх готового транскрипта.
|
||||
|
||||
### Диаризация — разделение говорящих
|
||||
|
||||
**Что:** Опциональный пост-процессинг (не четвёртый бэкенд): сегменты
|
||||
уже несут таймкоды; диаризация даёт интервалы «кто когда говорил»;
|
||||
merge по перекрытию интервалов; formatter ломает абзац на смене спикера
|
||||
и подписывает `Speaker 1:`. Ставится как extra:
|
||||
`uv sync --extra diarization`.
|
||||
|
||||
**Почему:** Без спикеров MoM не собрать — это ключевой разрыв с облаком
|
||||
по внешнему ревью, и никакое качество распознавания его не компенсирует.
|
||||
Заодно естественно решает «разбивку на реплики» (приоритет №4).
|
||||
|
||||
**Варианты реализации (ключевое решение, нужен ADR):**
|
||||
|
||||
- **sherpa-onnx** — диаризация целиком на onnxruntime (сегментация
|
||||
pyannote в ONNX + спикер-эмбеддинги), без torch, в духе нашего
|
||||
onnx-стека и «no cloud, no API keys».
|
||||
- **pyannote.audio** — стандарт качества, но тянет torch и требует
|
||||
HF-токен с принятием лицензии моделей — трение с духом проекта.
|
||||
|
||||
**Уточнить перед запуском:** качество обоих вариантов на русской речи
|
||||
и перекрывающихся репликах; скорость на CPU (диаризация — второй проход
|
||||
по всему аудио); лицензии моделей сегментации/эмбеддингов.
|
||||
|
||||
---
|
||||
|
||||
### Ручка нарезки абзацев в formatter
|
||||
|
||||
**Что:** «Минутные простыни» в транскрипте — не свойство модели, а наши
|
||||
константы группировки `_PAUSE_THRESHOLD_S = 2.0` / `_MAX_PARAGRAPH_S =
|
||||
60.0` в `formatter.py` (сырых сегментов много: 23-минутная запись — 360
|
||||
сегментов, ~4 с на реплику). Вынести в опцию/конфиг или уменьшить
|
||||
дефолт.
|
||||
|
||||
**Почему откладывается:** при диаризации абзацы будут ломаться по смене
|
||||
спикера естественно — сначала решить с диаризацией, чтобы не делать
|
||||
ручку, которая устареет.
|
||||
|
||||
---
|
||||
|
||||
### Словарь замен технических терминов — запасной план
|
||||
|
||||
**Что:** Пост-обработка текста сегментов словарём замен по границам слов
|
||||
(`CSW → CSV`, `софтп → SFTP`, `ямлик → YAML`, `Spark и Scale → Spark
|
||||
SQL`), словарь пользовательский в `.transcriber.toml`.
|
||||
|
||||
**Почему запасной:** это тот же класс ошибок, что «кириллизация латиницы
|
||||
и искажение имён» из [ADR-006](adr/006-onnx-asr-backend.md), и первым
|
||||
его должен попробовать закрыть контекстный декодер GigaAM v3 RNN-T
|
||||
(первый пункт бэклога). Заводить словарь — только если бенчмарк RNN-T
|
||||
термины не вытянет.
|
||||
|
||||
---
|
||||
|
||||
## Авто-детект и UX
|
||||
|
||||
### Включение `onnx` в `--device auto`
|
||||
|
||||
**Что:** После периода стабилизации `--device onnx` (несколько недель production-использования без жалоб) — рассмотреть включение в auto-detect chain.
|
||||
|
||||
**Порядок в chain (предложение):** CUDA → onnx (если CPU x86_64) → OpenVINO → CPU.
|
||||
|
||||
**Почему откладывается:** политика experimental backend — не сюрпризить существующих пользователей до накопления опыта. Источник: [ADR-006](adr/006-onnx-asr-backend.md#решение).
|
||||
|
||||
---
|
||||
|
||||
## Отклонённые направления
|
||||
|
||||
*(пока пусто — добавлять сюда то, что попробовали и решили не делать, с причиной)*
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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,7 +122,7 @@ 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,
|
||||
@@ -67,11 +134,48 @@ def main(
|
||||
),
|
||||
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}
|
||||
@@ -218,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(
|
||||
@@ -353,6 +458,7 @@ def _run_batch(
|
||||
style="green",
|
||||
)
|
||||
processed += 1
|
||||
_print_quality_warnings(result, file.name)
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except Exception as exc:
|
||||
|
||||
@@ -22,11 +22,12 @@ DEVICE_DEFAULTS: dict[str, dict[str, str]] = {
|
||||
"openvino": {"model": "medium", "compute_type": "int8"},
|
||||
"openvino-gpu": {"model": "medium", "compute_type": "int8"},
|
||||
"openvino-cpu": {"model": "medium", "compute_type": "int8"},
|
||||
"onnx": {"model": "gigaam-v3", "compute_type": "int8"},
|
||||
}
|
||||
|
||||
# Одно место правды для допустимых ключей конфига
|
||||
_VALID_KEYS = set(HARDCODED_DEFAULTS)
|
||||
_VALID_DEVICES = {"auto", "cpu", "cuda", "openvino", "openvino-gpu", "openvino-cpu"}
|
||||
_VALID_DEVICES = {"auto", "cpu", "cuda", "openvino", "openvino-gpu", "openvino-cpu", "onnx"}
|
||||
|
||||
|
||||
def find_config_file() -> Path | None:
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Установка пункта Transcribe в меню SendTo проводника Windows."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CMD_NAME = "Transcribe.cmd"
|
||||
CMD_ENCODING = "oem"
|
||||
|
||||
|
||||
def get_sendto_dir() -> Path:
|
||||
"""Возвращает путь к пользовательской папке SendTo."""
|
||||
appdata = os.environ.get("APPDATA")
|
||||
if appdata is None:
|
||||
raise RuntimeError("Переменная окружения APPDATA не задана.")
|
||||
return Path(appdata) / "Microsoft" / "Windows" / "SendTo"
|
||||
|
||||
|
||||
def get_transcribe_exe() -> Path:
|
||||
"""Возвращает путь к transcribe.exe рядом с текущим интерпретатором."""
|
||||
transcribe_exe = Path(sys.executable).parent / "transcribe.exe"
|
||||
if not transcribe_exe.exists():
|
||||
raise RuntimeError(
|
||||
f"Не найден transcribe.exe рядом с Python: {transcribe_exe}. "
|
||||
"Выполните uv sync и повторите установку пункта меню."
|
||||
)
|
||||
return transcribe_exe
|
||||
|
||||
|
||||
def install_menu() -> Path:
|
||||
"""Создаёт или обновляет Transcribe.cmd в папке SendTo."""
|
||||
sendto_dir = get_sendto_dir()
|
||||
transcribe_exe = get_transcribe_exe()
|
||||
cmd_path = sendto_dir / CMD_NAME
|
||||
content = f'@echo off\r\n"{transcribe_exe}" %*\r\npause\r\n'
|
||||
|
||||
try:
|
||||
encoded_content = content.encode(CMD_ENCODING)
|
||||
except UnicodeEncodeError as exc:
|
||||
raise RuntimeError(
|
||||
"Путь к transcribe.exe содержит символы, которые нельзя записать "
|
||||
"в OEM-кодировке cmd.exe. Установите проект в путь без таких символов "
|
||||
"и повторите --install-menu."
|
||||
) from exc
|
||||
|
||||
sendto_dir.mkdir(parents=True, exist_ok=True)
|
||||
cmd_path.write_bytes(encoded_content)
|
||||
return cmd_path
|
||||
|
||||
|
||||
def uninstall_menu() -> Path | None:
|
||||
"""Удаляет Transcribe.cmd из папки SendTo, если он существует."""
|
||||
cmd_path = get_sendto_dir() / CMD_NAME
|
||||
if not cmd_path.exists():
|
||||
return None
|
||||
cmd_path.unlink()
|
||||
return cmd_path
|
||||
@@ -4,6 +4,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from .quality import TAIL_GAP_WARN_S, find_repetition_blocks, tail_gap
|
||||
from .types import Segment, TranscribeResult
|
||||
|
||||
_PAUSE_THRESHOLD_S = 2.0 # пауза между сегментами для разбиения на абзацы
|
||||
@@ -63,7 +64,7 @@ def format_timestamp(seconds: float, use_hours: bool = False) -> str:
|
||||
return f"{minutes:02d}:{secs:02d}.{centiseconds:02d}"
|
||||
|
||||
|
||||
def _format_duration(seconds: float) -> str:
|
||||
def format_duration(seconds: float) -> str:
|
||||
"""Человекочитаемая длительность для метаданных в шапке транскрипта."""
|
||||
total = int(seconds)
|
||||
h = total // 3600
|
||||
@@ -92,7 +93,20 @@ def format_transcript(
|
||||
lines.append(f"- **Дата транскрипции**: {date.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
lines.append(f"- **Модель**: {model_name}")
|
||||
lines.append(f"- **Язык**: {result.language} ({language_mode})")
|
||||
lines.append(f"- **Длительность**: {_format_duration(result.duration)}")
|
||||
lines.append(f"- **Длительность**: {format_duration(result.duration)}")
|
||||
if tail_gap(result) > TAIL_GAP_WARN_S:
|
||||
last_end = result.segments[-1].end
|
||||
lines.append(
|
||||
f"- **Внимание**: транскрипт покрывает {format_duration(last_end)} "
|
||||
f"из {format_duration(result.duration)} — возможна потеря хвоста записи"
|
||||
)
|
||||
for block in find_repetition_blocks(result.segments):
|
||||
start = format_timestamp(block.start, use_hours=use_hours)
|
||||
end = format_timestamp(block.end, use_hours=use_hours)
|
||||
lines.append(
|
||||
f"- **Внимание**: повторы в [{start} - {end}] ({block.count}×) "
|
||||
"— возможны галлюцинации модели"
|
||||
)
|
||||
lines.append(f"- **Устройство**: {device_info}")
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Эвристики качества транскрипта."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .types import Segment, TranscribeResult
|
||||
|
||||
TAIL_GAP_WARN_S = 120.0
|
||||
REPETITION_MIN_RUN = 4
|
||||
REPETITION_MIN_RUN_SHORT = 10
|
||||
REPETITION_MIN_LEN = 6
|
||||
|
||||
_PUNCTUATION_TO_REMOVE = ".,!?…:;—–-\"'«»()[]<>"
|
||||
_REMOVE_PUNCTUATION = str.maketrans("", "", _PUNCTUATION_TO_REMOVE)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RepetitionBlock:
|
||||
start: float
|
||||
end: float
|
||||
count: int
|
||||
text: str
|
||||
|
||||
|
||||
def tail_gap(result: TranscribeResult) -> float:
|
||||
"""Возвращает непокрытый хвост записи в секундах."""
|
||||
if not result.segments:
|
||||
return 0.0
|
||||
return max(0.0, result.duration - result.segments[-1].end)
|
||||
|
||||
|
||||
def _normalize(text: str) -> str:
|
||||
"""Нормализует текст сегмента для поиска межсегментных повторов."""
|
||||
text = text.casefold()
|
||||
text = text.translate(_REMOVE_PUNCTUATION)
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def find_repetition_blocks(segments: list[Segment]) -> list[RepetitionBlock]:
|
||||
"""Находит серии подряд идущих одинаковых сегментов."""
|
||||
blocks: list[RepetitionBlock] = []
|
||||
run_start = 0
|
||||
run_norm = ""
|
||||
|
||||
def append_run(run_end: int) -> None:
|
||||
count = run_end - run_start
|
||||
if not run_norm:
|
||||
return
|
||||
min_run = (
|
||||
REPETITION_MIN_RUN
|
||||
if len(run_norm) >= REPETITION_MIN_LEN
|
||||
else REPETITION_MIN_RUN_SHORT
|
||||
)
|
||||
if count >= min_run:
|
||||
blocks.append(
|
||||
RepetitionBlock(
|
||||
start=segments[run_start].start,
|
||||
end=segments[run_end - 1].end,
|
||||
count=count,
|
||||
text=segments[run_start].text,
|
||||
)
|
||||
)
|
||||
|
||||
for index, segment in enumerate(segments):
|
||||
norm = _normalize(segment.text)
|
||||
if index == 0:
|
||||
run_start = 0
|
||||
run_norm = norm
|
||||
continue
|
||||
if norm == run_norm:
|
||||
continue
|
||||
append_run(index)
|
||||
run_start = index
|
||||
run_norm = norm
|
||||
|
||||
if segments:
|
||||
append_run(len(segments))
|
||||
|
||||
return blocks
|
||||
@@ -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
|
||||
@@ -921,3 +922,249 @@ def test_cli_threads_negative_rejected(tmp_path):
|
||||
audio.write_bytes(b"fake")
|
||||
out = runner.invoke(app, [str(audio), "--threads", "-1"])
|
||||
assert out.exit_code != 0
|
||||
|
||||
|
||||
# === SendTo context menu flags ===
|
||||
|
||||
|
||||
def test_cli_install_menu_success(tmp_path):
|
||||
cmd_path = tmp_path / "Transcribe.cmd"
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.install_context_menu", return_value=cmd_path) as mock_install,
|
||||
patch("local_transcriber.cli.load_config") as mock_load_config,
|
||||
patch("local_transcriber.cli.sys") as mock_sys,
|
||||
):
|
||||
mock_sys.platform = "win32"
|
||||
out = runner.invoke(app, ["--install-menu"])
|
||||
|
||||
assert out.exit_code == 0
|
||||
assert "Пункт меню установлен" in out.output
|
||||
assert cmd_path.name in out.output
|
||||
mock_install.assert_called_once_with()
|
||||
mock_load_config.assert_not_called()
|
||||
|
||||
|
||||
def test_cli_uninstall_menu_success(tmp_path):
|
||||
cmd_path = tmp_path / "Transcribe.cmd"
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.uninstall_context_menu", return_value=cmd_path) as mock_uninstall,
|
||||
patch("local_transcriber.cli.load_config") as mock_load_config,
|
||||
patch("local_transcriber.cli.sys") as mock_sys,
|
||||
):
|
||||
mock_sys.platform = "win32"
|
||||
out = runner.invoke(app, ["--uninstall-menu"])
|
||||
|
||||
assert out.exit_code == 0
|
||||
assert "Пункт меню удалён" in out.output
|
||||
assert cmd_path.name in out.output
|
||||
mock_uninstall.assert_called_once_with()
|
||||
mock_load_config.assert_not_called()
|
||||
|
||||
|
||||
def test_cli_uninstall_menu_missing_is_success():
|
||||
with (
|
||||
patch("local_transcriber.cli.uninstall_context_menu", return_value=None),
|
||||
patch("local_transcriber.cli.sys") as mock_sys,
|
||||
):
|
||||
mock_sys.platform = "win32"
|
||||
out = runner.invoke(app, ["--uninstall-menu"])
|
||||
|
||||
assert out.exit_code == 0
|
||||
assert "не был установлен" in out.output
|
||||
|
||||
|
||||
def test_cli_menu_flags_are_mutually_exclusive():
|
||||
out = runner.invoke(app, ["--install-menu", "--uninstall-menu"])
|
||||
|
||||
assert out.exit_code == 2
|
||||
assert "несовместимы" in out.output
|
||||
|
||||
|
||||
def test_cli_menu_flag_with_file_is_rejected(tmp_path):
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
|
||||
out = runner.invoke(app, [str(audio), "--install-menu"])
|
||||
|
||||
assert out.exit_code == 2
|
||||
assert "нельзя использовать вместе с файлами" in out.output
|
||||
|
||||
|
||||
def test_cli_no_files_and_no_menu_flags_is_rejected():
|
||||
out = runner.invoke(app, [])
|
||||
|
||||
assert out.exit_code == 2
|
||||
assert "Укажите хотя бы один файл" in out.output
|
||||
|
||||
|
||||
def test_cli_menu_flags_available_only_on_windows():
|
||||
with patch("local_transcriber.cli.sys") as mock_sys:
|
||||
mock_sys.platform = "linux"
|
||||
out = runner.invoke(app, ["--install-menu"])
|
||||
|
||||
assert out.exit_code == 1
|
||||
assert "только на Windows" in out.output
|
||||
|
||||
|
||||
def test_cli_menu_runtime_error_has_no_verbose_hint():
|
||||
with (
|
||||
patch("local_transcriber.cli.install_context_menu", side_effect=RuntimeError("нет APPDATA")),
|
||||
patch("local_transcriber.cli.sys") as mock_sys,
|
||||
):
|
||||
mock_sys.platform = "win32"
|
||||
out = runner.invoke(app, ["--install-menu"])
|
||||
|
||||
assert out.exit_code == 1
|
||||
assert "нет APPDATA" in out.output
|
||||
assert "--verbose" not in out.output
|
||||
|
||||
|
||||
def test_cli_tail_gap_quality_warning_single(tmp_path):
|
||||
audio = tmp_path / "tail.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
result = _make_result(
|
||||
segments=[Segment(start=0.0, end=60.0, text="Фраза")],
|
||||
duration=600.0,
|
||||
)
|
||||
|
||||
patches = _single_patches(result=result, tmp_file=audio)
|
||||
with (
|
||||
patches[0],
|
||||
patches[1],
|
||||
patches[2],
|
||||
patches[3],
|
||||
patches[4],
|
||||
patches[5],
|
||||
patch("local_transcriber.cli.console", Console(stderr=True, width=1000)),
|
||||
):
|
||||
out = runner.invoke(app, [str(audio)])
|
||||
|
||||
assert out.exit_code == 0
|
||||
assert (
|
||||
"Внимание: транскрипт покрывает 01:00 из 10:00 — "
|
||||
"возможна потеря хвоста записи. Попробуйте другой --device."
|
||||
) in out.output
|
||||
|
||||
|
||||
def test_cli_repetition_quality_warning_single(tmp_path):
|
||||
audio = tmp_path / "repeat.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
result = _make_result(
|
||||
segments=[
|
||||
Segment(start=10.0, end=11.0, text="Повторяемая фраза"),
|
||||
Segment(start=11.0, end=12.0, text="повторяемая фраза"),
|
||||
Segment(start=12.0, end=13.0, text="повторяемая фраза"),
|
||||
Segment(start=13.0, end=14.0, text="повторяемая фраза"),
|
||||
],
|
||||
duration=60.0,
|
||||
)
|
||||
|
||||
patches = _single_patches(result=result, tmp_file=audio)
|
||||
with (
|
||||
patches[0],
|
||||
patches[1],
|
||||
patches[2],
|
||||
patches[3],
|
||||
patches[4],
|
||||
patches[5],
|
||||
patch("local_transcriber.cli.console", Console(stderr=True, width=1000)),
|
||||
):
|
||||
out = runner.invoke(app, [str(audio)])
|
||||
|
||||
assert out.exit_code == 0
|
||||
assert (
|
||||
"Внимание: блоки повторов: [00:10.00 - 00:14.00] (4×) — "
|
||||
"возможны галлюцинации модели. Попробуйте другой --device."
|
||||
) in out.output
|
||||
|
||||
|
||||
def test_cli_quality_warning_batch_includes_file_name(tmp_path):
|
||||
a = tmp_path / "a.mp3"
|
||||
b = tmp_path / "b.mp3"
|
||||
a.write_bytes(b"fake")
|
||||
b.write_bytes(b"fake")
|
||||
|
||||
result_warn = _make_result(
|
||||
segments=[Segment(start=0.0, end=60.0, text="Фраза")],
|
||||
duration=600.0,
|
||||
)
|
||||
result_ok = _make_result()
|
||||
model = _make_model()
|
||||
backend = _make_backend()
|
||||
tfr_warn = _make_tfr(result=result_warn, model=model, backend=backend)
|
||||
tfr_ok = _make_tfr(result=result_ok, model=model, backend=backend)
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||
patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")),
|
||||
patch("local_transcriber.cli._transcribe_file", side_effect=[tfr_warn, tfr_ok]),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
patch("local_transcriber.cli.console", Console(stderr=True, width=1000)),
|
||||
):
|
||||
out = runner.invoke(app, [str(a), str(b)])
|
||||
|
||||
assert out.exit_code == 0
|
||||
assert (
|
||||
" a.mp3: транскрипт покрывает 01:00 из 10:00 — "
|
||||
"возможна потеря хвоста записи"
|
||||
) in out.output
|
||||
|
||||
|
||||
def test_cli_repetition_quality_warning_truncates_after_three_blocks(tmp_path):
|
||||
audio = tmp_path / "repeat-many.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
|
||||
def run(start, count, text):
|
||||
return [
|
||||
Segment(start=start + index, end=start + index + 1.0, text=text)
|
||||
for index in range(count)
|
||||
]
|
||||
|
||||
result = _make_result(
|
||||
segments=[
|
||||
*run(10.0, 6, "Первый повтор"),
|
||||
Segment(start=18.0, end=19.0, text="Разрыв один"),
|
||||
*run(20.0, 5, "Второй повтор"),
|
||||
Segment(start=28.0, end=29.0, text="Разрыв два"),
|
||||
*run(30.0, 4, "Третий повтор"),
|
||||
Segment(start=38.0, end=39.0, text="Разрыв три"),
|
||||
*run(40.0, 4, "Четвёртый повтор"),
|
||||
],
|
||||
duration=90.0,
|
||||
)
|
||||
|
||||
patches = _single_patches(result=result, tmp_file=audio)
|
||||
with (
|
||||
patches[0],
|
||||
patches[1],
|
||||
patches[2],
|
||||
patches[3],
|
||||
patches[4],
|
||||
patches[5],
|
||||
patch("local_transcriber.cli.console", Console(stderr=True, width=1000)),
|
||||
):
|
||||
out = runner.invoke(app, [str(audio)])
|
||||
|
||||
assert out.exit_code == 0
|
||||
assert (
|
||||
"Внимание: блоки повторов: [00:10.00 - 00:16.00] (6×); "
|
||||
"[00:20.00 - 00:25.00] (5×); [00:30.00 - 00:34.00] (4×) "
|
||||
"(+ ещё 1) — возможны галлюцинации модели. Попробуйте другой --device."
|
||||
) in out.output
|
||||
|
||||
|
||||
def test_cli_default_result_has_no_quality_warnings(tmp_path):
|
||||
audio = tmp_path / "normal.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
|
||||
patches = _single_patches(tmp_file=audio)
|
||||
with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5]:
|
||||
out = runner.invoke(app, [str(audio)])
|
||||
|
||||
assert out.exit_code == 0
|
||||
assert "потеря хвоста" not in out.output
|
||||
assert "галлюцинации" not in out.output
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from local_transcriber import context_menu
|
||||
|
||||
|
||||
def _prepare_exe(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
scripts_dir = tmp_path / "venv" / "Scripts"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
python_exe = scripts_dir / "python.exe"
|
||||
transcribe_exe = scripts_dir / "transcribe.exe"
|
||||
python_exe.write_bytes(b"")
|
||||
transcribe_exe.write_bytes(b"")
|
||||
monkeypatch.setattr(context_menu.sys, "executable", str(python_exe))
|
||||
return transcribe_exe
|
||||
|
||||
|
||||
def test_install_menu_creates_expected_cmd(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming"))
|
||||
monkeypatch.setattr(context_menu, "CMD_ENCODING", "utf-8")
|
||||
transcribe_exe = _prepare_exe(tmp_path, monkeypatch)
|
||||
|
||||
cmd_path = context_menu.install_menu()
|
||||
|
||||
assert cmd_path.name == "Transcribe.cmd"
|
||||
assert cmd_path.exists()
|
||||
assert cmd_path.read_bytes() == (
|
||||
f'@echo off\r\n"{transcribe_exe}" %*\r\npause\r\n'.encode("utf-8")
|
||||
)
|
||||
assert b"chcp" not in cmd_path.read_bytes().lower()
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="Кодировка oem доступна только на Windows")
|
||||
def test_install_menu_writes_real_oem_encoding_on_windows(tmp_path, monkeypatch):
|
||||
appdata = tmp_path / "AppData" / "Roaming"
|
||||
scripts_dir = tmp_path / "проект" / "Scripts"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
python_exe = scripts_dir / "python.exe"
|
||||
transcribe_exe = scripts_dir / "transcribe.exe"
|
||||
python_exe.write_bytes(b"")
|
||||
transcribe_exe.write_bytes(b"")
|
||||
monkeypatch.setenv("APPDATA", str(appdata))
|
||||
monkeypatch.setattr(context_menu.sys, "executable", str(python_exe))
|
||||
|
||||
cmd_path = context_menu.install_menu()
|
||||
|
||||
assert context_menu.CMD_ENCODING == "oem"
|
||||
assert cmd_path.read_bytes() == (
|
||||
f'@echo off\r\n"{transcribe_exe}" %*\r\npause\r\n'.encode("oem")
|
||||
)
|
||||
|
||||
|
||||
def test_install_menu_overwrites_existing_file(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming"))
|
||||
monkeypatch.setattr(context_menu, "CMD_ENCODING", "utf-8")
|
||||
_prepare_exe(tmp_path, monkeypatch)
|
||||
|
||||
cmd_path = context_menu.install_menu()
|
||||
cmd_path.write_text("old", encoding="utf-8")
|
||||
|
||||
second_path = context_menu.install_menu()
|
||||
|
||||
assert second_path == cmd_path
|
||||
assert "old" not in cmd_path.read_text(encoding="utf-8")
|
||||
assert "%*" in cmd_path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_install_menu_creates_missing_sendto_dir(tmp_path, monkeypatch):
|
||||
appdata = tmp_path / "AppData" / "Roaming"
|
||||
monkeypatch.setenv("APPDATA", str(appdata))
|
||||
monkeypatch.setattr(context_menu, "CMD_ENCODING", "utf-8")
|
||||
_prepare_exe(tmp_path, monkeypatch)
|
||||
|
||||
cmd_path = context_menu.install_menu()
|
||||
|
||||
assert cmd_path.parent == appdata / "Microsoft" / "Windows" / "SendTo"
|
||||
assert cmd_path.parent.is_dir()
|
||||
|
||||
|
||||
def test_install_menu_oem_encoding_error_is_runtime_error(tmp_path, monkeypatch):
|
||||
appdata = tmp_path / "AppData" / "Roaming"
|
||||
cmd_path = appdata / "Microsoft" / "Windows" / "SendTo" / "Transcribe.cmd"
|
||||
monkeypatch.setenv("APPDATA", str(appdata))
|
||||
monkeypatch.setattr(context_menu, "CMD_ENCODING", "ascii")
|
||||
monkeypatch.setattr(
|
||||
context_menu,
|
||||
"get_transcribe_exe",
|
||||
lambda: tmp_path / "测试" / "transcribe.exe",
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="OEM"):
|
||||
context_menu.install_menu()
|
||||
|
||||
assert not cmd_path.exists()
|
||||
|
||||
|
||||
def test_uninstall_menu_removes_file_and_missing_is_not_error(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming"))
|
||||
monkeypatch.setattr(context_menu, "CMD_ENCODING", "utf-8")
|
||||
_prepare_exe(tmp_path, monkeypatch)
|
||||
cmd_path = context_menu.install_menu()
|
||||
|
||||
removed_path = context_menu.uninstall_menu()
|
||||
missing_path = context_menu.uninstall_menu()
|
||||
|
||||
assert removed_path == cmd_path
|
||||
assert not cmd_path.exists()
|
||||
assert missing_path is None
|
||||
|
||||
|
||||
def test_get_sendto_dir_requires_appdata(monkeypatch):
|
||||
monkeypatch.delenv("APPDATA", raising=False)
|
||||
|
||||
with pytest.raises(RuntimeError, match="APPDATA"):
|
||||
context_menu.get_sendto_dir()
|
||||
|
||||
|
||||
def test_get_transcribe_exe_requires_existing_exe(tmp_path, monkeypatch):
|
||||
scripts_dir = tmp_path / "venv" / "Scripts"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
python_exe = scripts_dir / "python.exe"
|
||||
python_exe.write_bytes(b"")
|
||||
monkeypatch.setattr(context_menu.sys, "executable", str(python_exe))
|
||||
|
||||
with pytest.raises(RuntimeError, match="uv sync"):
|
||||
context_menu.get_transcribe_exe()
|
||||
@@ -172,3 +172,141 @@ def test_write_transcript(tmp_path):
|
||||
out = tmp_path / "output.md"
|
||||
write_transcript("# Test content\n", out)
|
||||
assert out.read_text(encoding="utf-8") == "# Test content\n"
|
||||
|
||||
|
||||
def test_format_transcript_tail_gap_warning():
|
||||
result = TranscribeResult(
|
||||
segments=[Segment(start=0.0, end=60.0, text=" Фраза.")],
|
||||
language="ru",
|
||||
language_probability=0.95,
|
||||
duration=600.0,
|
||||
device_used="cpu",
|
||||
)
|
||||
|
||||
content = format_transcript(
|
||||
result,
|
||||
source_filename="tail.mp3",
|
||||
model_name="medium",
|
||||
device_info="CPU",
|
||||
language_mode="forced",
|
||||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||||
)
|
||||
|
||||
assert "возможна потеря хвоста" in content
|
||||
assert "транскрипт покрывает 01:00 из 10:00" in content
|
||||
|
||||
|
||||
def test_format_transcript_no_tail_gap_warning_for_small_gap():
|
||||
result = TranscribeResult(
|
||||
segments=[Segment(start=0.0, end=60.0, text=" Фраза.")],
|
||||
language="ru",
|
||||
language_probability=0.95,
|
||||
duration=179.99,
|
||||
device_used="cpu",
|
||||
)
|
||||
|
||||
content = format_transcript(
|
||||
result,
|
||||
source_filename="ok.mp3",
|
||||
model_name="medium",
|
||||
device_info="CPU",
|
||||
language_mode="forced",
|
||||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||||
)
|
||||
|
||||
assert "потеря хвоста" not in content
|
||||
|
||||
|
||||
def test_format_transcript_no_tail_gap_warning_for_exact_threshold():
|
||||
result = TranscribeResult(
|
||||
segments=[Segment(start=0.0, end=60.0, text=" Фраза.")],
|
||||
language="ru",
|
||||
language_probability=0.95,
|
||||
duration=180.0,
|
||||
device_used="cpu",
|
||||
)
|
||||
|
||||
content = format_transcript(
|
||||
result,
|
||||
source_filename="ok.mp3",
|
||||
model_name="medium",
|
||||
device_info="CPU",
|
||||
language_mode="forced",
|
||||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||||
)
|
||||
|
||||
assert "потеря хвоста" not in content
|
||||
|
||||
|
||||
def test_format_transcript_repetition_warning():
|
||||
result = TranscribeResult(
|
||||
segments=[
|
||||
Segment(start=10.0, end=11.0, text=" Повторяемая фраза."),
|
||||
Segment(start=11.0, end=12.0, text=" повторяемая фраза"),
|
||||
Segment(start=12.0, end=13.0, text=" «Повторяемая фраза»"),
|
||||
Segment(start=13.0, end=14.0, text=" повторяемая фраза…"),
|
||||
],
|
||||
language="ru",
|
||||
language_probability=0.95,
|
||||
duration=60.0,
|
||||
device_used="cpu",
|
||||
)
|
||||
|
||||
content = format_transcript(
|
||||
result,
|
||||
source_filename="repeat.mp3",
|
||||
model_name="medium",
|
||||
device_info="CPU",
|
||||
language_mode="forced",
|
||||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||||
)
|
||||
|
||||
assert "повторы в [00:10.00 - 00:14.00] (4×)" in content
|
||||
assert "возможны галлюцинации" in content
|
||||
|
||||
|
||||
def test_format_transcript_repetition_warning_uses_hours():
|
||||
result = TranscribeResult(
|
||||
segments=[
|
||||
Segment(start=3600.0, end=3601.0, text=" Повтор."),
|
||||
Segment(start=3601.0, end=3602.0, text=" повтор"),
|
||||
Segment(start=3602.0, end=3603.0, text=" повтор"),
|
||||
Segment(start=3603.0, end=3604.0, text=" повтор"),
|
||||
],
|
||||
language="ru",
|
||||
language_probability=0.95,
|
||||
duration=3700.0,
|
||||
device_used="cpu",
|
||||
)
|
||||
|
||||
content = format_transcript(
|
||||
result,
|
||||
source_filename="long-repeat.mp3",
|
||||
model_name="medium",
|
||||
device_info="CPU",
|
||||
language_mode="forced",
|
||||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||||
)
|
||||
|
||||
assert "повторы в [01:00:00.00 - 01:00:04.00] (4×)" in content
|
||||
|
||||
|
||||
def test_format_transcript_without_anomalies_has_no_warning_lines():
|
||||
result = TranscribeResult(
|
||||
segments=[Segment(start=0.0, end=60.0, text=" Обычная запись.")],
|
||||
language="ru",
|
||||
language_probability=0.95,
|
||||
duration=120.0,
|
||||
device_used="cpu",
|
||||
)
|
||||
|
||||
content = format_transcript(
|
||||
result,
|
||||
source_filename="ok.mp3",
|
||||
model_name="medium",
|
||||
device_info="CPU",
|
||||
language_mode="forced",
|
||||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||||
)
|
||||
|
||||
assert "Внимание" not in content
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
"""Tests for onnx-asr backend."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from local_transcriber.backends.onnx_asr import OnnxAsrBackend, MODEL_ALIASES
|
||||
from local_transcriber.types import Segment, TranscribeResult
|
||||
|
||||
|
||||
class FakeVadSegment:
|
||||
"""Mimics onnx-asr SegmentResult."""
|
||||
|
||||
def __init__(self, start, end, text):
|
||||
self.start = start
|
||||
self.end = end
|
||||
self.text = text
|
||||
|
||||
|
||||
class TestEnsureModelAvailable:
|
||||
def test_returns_model_id_for_gigaam(self):
|
||||
backend = OnnxAsrBackend()
|
||||
result = backend.ensure_model_available("gigaam-v3", "int8")
|
||||
assert result == "gigaam-v3-ctc"
|
||||
|
||||
def test_returns_model_id_for_parakeet(self):
|
||||
backend = OnnxAsrBackend()
|
||||
result = backend.ensure_model_available("parakeet-v3", "fp16")
|
||||
assert result == "nemo-parakeet-tdt-0.6b-v3"
|
||||
|
||||
def test_stores_compute_type(self):
|
||||
backend = OnnxAsrBackend()
|
||||
backend.ensure_model_available("gigaam-v3", "float32")
|
||||
assert backend._resolved_model_id == "gigaam-v3-ctc"
|
||||
assert backend.actual_compute_type == "float32"
|
||||
|
||||
|
||||
class TestCreateModel:
|
||||
def test_calls_load_model_with_correct_args(self, monkeypatch):
|
||||
"""Verify create_model passes correct args to onnx_asr.load_model."""
|
||||
calls = []
|
||||
|
||||
def fake_load_model(model=None, path=None, quantization=None,
|
||||
**kwargs):
|
||||
calls.append({
|
||||
"model": model, "path": path, "quantization": quantization,
|
||||
})
|
||||
return FakeAsrAdapter()
|
||||
|
||||
class FakeAsrAdapter:
|
||||
def with_vad(self, vad):
|
||||
return self
|
||||
|
||||
monkeypatch.setattr("onnx_asr.load_model", fake_load_model)
|
||||
|
||||
backend = OnnxAsrBackend()
|
||||
backend.actual_compute_type = "int8"
|
||||
model = backend.create_model("gigaam-v3-ctc", "onnx", "int8")
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["quantization"] == "int8"
|
||||
assert model is not None
|
||||
|
||||
def test_loads_silero_vad(self, monkeypatch):
|
||||
"""Verify Silero VAD is loaded and attached to model."""
|
||||
vad_calls = []
|
||||
|
||||
def fake_load_vad(model, **kwargs):
|
||||
vad_calls.append(model)
|
||||
return "fake_vad"
|
||||
|
||||
def fake_load_model(**kwargs):
|
||||
return FakeAsrAdapter()
|
||||
|
||||
class FakeAsrAdapter:
|
||||
def with_vad(self, vad):
|
||||
self._vad = vad
|
||||
return self
|
||||
|
||||
monkeypatch.setattr("onnx_asr.load_model", fake_load_model)
|
||||
monkeypatch.setattr("onnx_asr.load_vad", fake_load_vad)
|
||||
|
||||
backend = OnnxAsrBackend()
|
||||
model = backend.create_model("gigaam-v3-ctc", "onnx", "int8")
|
||||
|
||||
assert vad_calls == ["silero"]
|
||||
|
||||
def test_fp16_compute_type(self, monkeypatch):
|
||||
"""Verify fp16 compute_type is passed through."""
|
||||
calls = []
|
||||
|
||||
def fake_load_model(model=None, quantization=None, **kwargs):
|
||||
calls.append(quantization)
|
||||
return FakeAsrAdapter()
|
||||
|
||||
class FakeAsrAdapter:
|
||||
def with_vad(self, vad):
|
||||
return self
|
||||
|
||||
monkeypatch.setattr("onnx_asr.load_model", fake_load_model)
|
||||
monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kw: None)
|
||||
|
||||
backend = OnnxAsrBackend()
|
||||
backend.create_model("parakeet-v3", "onnx", "fp16")
|
||||
|
||||
assert calls == ["fp16"]
|
||||
|
||||
def test_float32_maps_to_none(self, monkeypatch):
|
||||
"""compute_type='float32' маппится в quantization=None (unquantized).
|
||||
|
||||
onnx-asr использует quantization как суффикс файла; для float32 нужен None,
|
||||
строка "float32" приведёт к попытке загрузить несуществующий файл.
|
||||
"""
|
||||
calls = []
|
||||
|
||||
def fake_load_model(model=None, quantization="MISSING", **kwargs):
|
||||
calls.append(quantization)
|
||||
return FakeAsrAdapter()
|
||||
|
||||
class FakeAsrAdapter:
|
||||
def with_vad(self, vad):
|
||||
return self
|
||||
|
||||
monkeypatch.setattr("onnx_asr.load_model", fake_load_model)
|
||||
monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kw: None)
|
||||
|
||||
backend = OnnxAsrBackend()
|
||||
backend.create_model("gigaam-v3-ctc", "onnx", "float32")
|
||||
|
||||
assert calls == [None]
|
||||
|
||||
def test_fp32_maps_to_none(self, monkeypatch):
|
||||
"""compute_type='fp32' тоже маппится в quantization=None."""
|
||||
calls = []
|
||||
|
||||
def fake_load_model(model=None, quantization="MISSING", **kwargs):
|
||||
calls.append(quantization)
|
||||
return FakeAsrAdapter()
|
||||
|
||||
class FakeAsrAdapter:
|
||||
def with_vad(self, vad):
|
||||
return self
|
||||
|
||||
monkeypatch.setattr("onnx_asr.load_model", fake_load_model)
|
||||
monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kw: None)
|
||||
|
||||
backend = OnnxAsrBackend()
|
||||
backend.create_model("gigaam-v3-ctc", "onnx", "fp32")
|
||||
|
||||
assert calls == [None]
|
||||
|
||||
def test_float16_alias_maps_to_fp16(self, monkeypatch):
|
||||
"""compute_type='float16' (CUDA-naming) маппится в onnx-asr 'fp16'."""
|
||||
calls = []
|
||||
|
||||
def fake_load_model(model=None, quantization=None, **kwargs):
|
||||
calls.append(quantization)
|
||||
return FakeAsrAdapter()
|
||||
|
||||
class FakeAsrAdapter:
|
||||
def with_vad(self, vad):
|
||||
return self
|
||||
|
||||
monkeypatch.setattr("onnx_asr.load_model", fake_load_model)
|
||||
monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kw: None)
|
||||
|
||||
backend = OnnxAsrBackend()
|
||||
backend.create_model("gigaam-v3-ctc", "onnx", "float16")
|
||||
|
||||
assert calls == ["fp16"]
|
||||
|
||||
def test_unknown_compute_type_raises(self, monkeypatch):
|
||||
"""Неподдерживаемый compute_type → ValueError, не silent fallback."""
|
||||
monkeypatch.setattr("onnx_asr.load_model", lambda **kw: None)
|
||||
monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kw: None)
|
||||
|
||||
backend = OnnxAsrBackend()
|
||||
with pytest.raises(ValueError, match="Неподдерживаемый compute_type"):
|
||||
backend.create_model("gigaam-v3-ctc", "onnx", "int8_float32")
|
||||
|
||||
|
||||
class TestTranscribe:
|
||||
def test_transcribe_collects_segments(self, monkeypatch, tmp_path):
|
||||
"""Verify transcribe maps VAD segments to project Segments."""
|
||||
wav_file = tmp_path / "test.wav"
|
||||
wav_file.write_bytes(b"fake audio")
|
||||
|
||||
audio_samples = [0.0] * 16000 # 1 second of silence
|
||||
|
||||
def fake_decode_audio(path, sampling_rate=16000):
|
||||
import numpy as np
|
||||
return np.array(audio_samples, dtype=np.float32)
|
||||
|
||||
class FakeModel:
|
||||
def recognize(self, waveform, sample_rate, language=None):
|
||||
yield FakeVadSegment(0.0, 1.0, "hello")
|
||||
yield FakeVadSegment(1.0, 2.5, "world")
|
||||
|
||||
monkeypatch.setattr("faster_whisper.decode_audio", fake_decode_audio)
|
||||
|
||||
backend = OnnxAsrBackend()
|
||||
backend.actual_compute_type = "int8"
|
||||
result = backend.transcribe(
|
||||
FakeModel(), wav_file, language=None,
|
||||
)
|
||||
|
||||
assert isinstance(result, TranscribeResult)
|
||||
assert len(result.segments) == 2
|
||||
assert result.segments[0] == Segment(start=0.0, end=1.0, text="hello")
|
||||
assert result.segments[1] == Segment(start=1.0, end=2.5, text="world")
|
||||
assert result.duration == 1.0 # 16000 samples / 16000 Hz
|
||||
|
||||
def test_transcribe_calls_on_segment(self, monkeypatch, tmp_path):
|
||||
"""Verify on_segment callback is invoked per segment."""
|
||||
wav_file = tmp_path / "test.wav"
|
||||
wav_file.write_bytes(b"fake audio")
|
||||
|
||||
def fake_decode_audio(path, sampling_rate=16000):
|
||||
import numpy as np
|
||||
return np.array([0.0] * 16000, dtype=np.float32)
|
||||
|
||||
segments_captured = []
|
||||
|
||||
class FakeModel:
|
||||
def recognize(self, waveform, sample_rate, language=None):
|
||||
yield FakeVadSegment(0.0, 2.0, "one")
|
||||
yield FakeVadSegment(2.0, 4.0, "two")
|
||||
|
||||
monkeypatch.setattr("faster_whisper.decode_audio", fake_decode_audio)
|
||||
|
||||
backend = OnnxAsrBackend()
|
||||
result = backend.transcribe(
|
||||
FakeModel(), wav_file, language=None,
|
||||
on_segment=lambda s: segments_captured.append(s),
|
||||
)
|
||||
|
||||
assert len(segments_captured) == 2
|
||||
assert segments_captured[0].text == "one"
|
||||
assert segments_captured[1].text == "two"
|
||||
|
||||
def test_transcribe_passes_language(self, monkeypatch, tmp_path):
|
||||
"""Verify language is passed to recognize()."""
|
||||
wav_file = tmp_path / "test.wav"
|
||||
wav_file.write_bytes(b"fake audio")
|
||||
|
||||
def fake_decode_audio(path, sampling_rate=16000):
|
||||
import numpy as np
|
||||
return np.array([0.0] * 16000, dtype=np.float32)
|
||||
|
||||
lang_received = []
|
||||
|
||||
class FakeModel:
|
||||
def recognize(self, waveform, sample_rate, language=None):
|
||||
lang_received.append(language)
|
||||
yield FakeVadSegment(0.0, 1.0, "text")
|
||||
|
||||
monkeypatch.setattr("faster_whisper.decode_audio", fake_decode_audio)
|
||||
|
||||
backend = OnnxAsrBackend()
|
||||
backend.transcribe(FakeModel(), wav_file, language="ru")
|
||||
|
||||
assert lang_received == ["ru"]
|
||||
|
||||
def test_transcribe_empty_audio(self, monkeypatch, tmp_path):
|
||||
"""Verify zero segments for silent audio."""
|
||||
wav_file = tmp_path / "test.wav"
|
||||
wav_file.write_bytes(b"fake audio")
|
||||
|
||||
def fake_decode_audio(path, sampling_rate=16000):
|
||||
import numpy as np
|
||||
return np.array([0.0] * 16000, dtype=np.float32)
|
||||
|
||||
class FakeModel:
|
||||
def recognize(self, waveform, sample_rate, language=None):
|
||||
# No segments yielded
|
||||
if False:
|
||||
yield
|
||||
|
||||
monkeypatch.setattr("faster_whisper.decode_audio", fake_decode_audio)
|
||||
|
||||
backend = OnnxAsrBackend()
|
||||
result = backend.transcribe(FakeModel(), wav_file, language=None)
|
||||
|
||||
assert len(result.segments) == 0
|
||||
assert result.language == "unknown"
|
||||
assert result.duration == 1.0
|
||||
|
||||
|
||||
class TestBackendRegistration:
|
||||
def test_get_backend_returns_onnx_backend(self):
|
||||
from local_transcriber.backends import get_backend
|
||||
backend = get_backend("onnx")
|
||||
assert isinstance(backend, OnnxAsrBackend)
|
||||
|
||||
|
||||
class TestModelAliases:
|
||||
def test_gigaam_v3_resolves(self):
|
||||
backend = OnnxAsrBackend()
|
||||
result = backend._resolve_model("gigaam-v3")
|
||||
assert result == "gigaam-v3-ctc"
|
||||
|
||||
def test_parakeet_v3_resolves(self):
|
||||
backend = OnnxAsrBackend()
|
||||
result = backend._resolve_model("parakeet-v3")
|
||||
assert result == "nemo-parakeet-tdt-0.6b-v3"
|
||||
|
||||
def test_raw_name_passes_through(self):
|
||||
backend = OnnxAsrBackend()
|
||||
result = backend._resolve_model("nemo-canary-1b-v2")
|
||||
assert result == "nemo-canary-1b-v2"
|
||||
|
||||
def test_unknown_alias_raises(self):
|
||||
backend = OnnxAsrBackend()
|
||||
with pytest.raises(ValueError, match="Неподдерживаемая модель"):
|
||||
backend._resolve_model("nonexistent-model")
|
||||
@@ -0,0 +1,125 @@
|
||||
import pytest
|
||||
|
||||
from local_transcriber.quality import (
|
||||
REPETITION_MIN_LEN,
|
||||
TAIL_GAP_WARN_S,
|
||||
_normalize,
|
||||
find_repetition_blocks,
|
||||
tail_gap,
|
||||
)
|
||||
from local_transcriber.types import Segment, TranscribeResult
|
||||
|
||||
|
||||
def _result(segments, duration):
|
||||
return TranscribeResult(
|
||||
segments=segments,
|
||||
language="ru",
|
||||
language_probability=0.95,
|
||||
duration=duration,
|
||||
device_used="cpu",
|
||||
)
|
||||
|
||||
|
||||
def _segments(texts, start=0.0):
|
||||
return [
|
||||
Segment(start=start + index, end=start + index + 1.0, text=text)
|
||||
for index, text in enumerate(texts)
|
||||
]
|
||||
|
||||
|
||||
def test_tail_gap_returns_positive_gap():
|
||||
result = _result([Segment(0.0, 10.0, "Текст")], duration=42.0)
|
||||
|
||||
assert tail_gap(result) == 32.0
|
||||
|
||||
|
||||
def test_tail_gap_empty_segments_returns_zero():
|
||||
assert tail_gap(_result([], duration=42.0)) == 0.0
|
||||
|
||||
|
||||
def test_tail_gap_negative_gap_returns_zero():
|
||||
result = _result([Segment(0.0, 43.0, "Текст")], duration=42.0)
|
||||
|
||||
assert tail_gap(result) == 0.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gap", [119.99, 120.0])
|
||||
def test_tail_gap_boundary_does_not_warn(gap):
|
||||
result = _result([Segment(0.0, 10.0, "Текст")], duration=10.0 + gap)
|
||||
|
||||
assert tail_gap(result) <= TAIL_GAP_WARN_S
|
||||
|
||||
|
||||
def test_tail_gap_boundary_warns_above_threshold():
|
||||
result = _result([Segment(0.0, 10.0, "Текст")], duration=130.01)
|
||||
|
||||
assert tail_gap(result) > TAIL_GAP_WARN_S
|
||||
|
||||
|
||||
def test_normalize_removes_case_punctuation_and_collapses_spaces():
|
||||
assert _normalize(' «ПРИВЕТ…» — (мир) [тест] <да> ') == "привет мир тест да"
|
||||
assert _normalize("раз–два-три: да; нет!") == "раздватри да нет"
|
||||
|
||||
|
||||
def test_normalize_punctuation_only_returns_empty():
|
||||
assert _normalize('.,!?…:;—–-"\'«»()[]<> ') == ""
|
||||
|
||||
|
||||
def test_find_repetition_blocks_four_long_segments_with_normalization():
|
||||
segments = _segments(["Повтор!", "повтор", "«ПОВТОР»", "повтор…"])
|
||||
|
||||
blocks = find_repetition_blocks(segments)
|
||||
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0].start == 0.0
|
||||
assert blocks[0].end == 4.0
|
||||
assert blocks[0].count == 4
|
||||
assert blocks[0].text == "Повтор!"
|
||||
|
||||
|
||||
def test_find_repetition_blocks_three_long_segments_is_empty():
|
||||
assert find_repetition_blocks(_segments(["Повтор", "повтор", "повтор"])) == []
|
||||
|
||||
|
||||
def test_find_repetition_blocks_length_boundary():
|
||||
assert find_repetition_blocks(_segments(["пять5"] * 4)) == []
|
||||
|
||||
blocks = find_repetition_blocks(_segments(["шесть6"] * 4))
|
||||
|
||||
assert len("шесть6") == REPETITION_MIN_LEN
|
||||
assert len(blocks) == 1
|
||||
|
||||
|
||||
def test_find_repetition_blocks_short_text_boundary():
|
||||
assert find_repetition_blocks(_segments(["Ага."] * 9)) == []
|
||||
|
||||
blocks = find_repetition_blocks(_segments(["Ага."] * 10))
|
||||
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0].count == 10
|
||||
|
||||
|
||||
def test_find_repetition_blocks_empty_normalized_text_is_ignored():
|
||||
assert find_repetition_blocks(_segments([":", "", " "] * 10)) == []
|
||||
|
||||
|
||||
def test_find_repetition_blocks_two_separate_runs():
|
||||
segments = [
|
||||
*_segments(["Первый повтор"] * 4),
|
||||
Segment(10.0, 11.0, "Разрыв"),
|
||||
*_segments(["Второй повтор"] * 4, start=20.0),
|
||||
]
|
||||
|
||||
blocks = find_repetition_blocks(segments)
|
||||
|
||||
assert len(blocks) == 2
|
||||
assert blocks[0].text == "Первый повтор"
|
||||
assert blocks[1].text == "Второй повтор"
|
||||
|
||||
|
||||
def test_find_repetition_blocks_empty_list_is_empty():
|
||||
assert find_repetition_blocks([]) == []
|
||||
|
||||
|
||||
def test_find_repetition_blocks_single_segment_is_empty():
|
||||
assert find_repetition_blocks([Segment(0.0, 1.0, "Повтор")]) == []
|
||||
@@ -301,6 +301,7 @@ source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "faster-whisper" },
|
||||
{ name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
{ name = "onnx-asr", extra = ["cpu", "hub"] },
|
||||
{ name = "openvino-genai", marker = "(platform_machine == 'AMD64' and sys_platform != 'darwin') or (platform_machine == 'x86_64' and sys_platform != 'darwin')" },
|
||||
{ name = "rich" },
|
||||
{ name = "socksio" },
|
||||
@@ -317,6 +318,7 @@ dev = [
|
||||
requires-dist = [
|
||||
{ name = "faster-whisper", specifier = ">=1.2.1" },
|
||||
{ name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=12.4" },
|
||||
{ name = "onnx-asr", extras = ["cpu", "hub"], specifier = ">=0.11.0,<0.12.0" },
|
||||
{ name = "openvino-genai", marker = "(platform_machine == 'AMD64' and sys_platform != 'darwin') or (platform_machine == 'x86_64' and sys_platform != 'darwin')", specifier = ">=2025.0" },
|
||||
{ name = "rich" },
|
||||
{ name = "socksio", specifier = ">=1.0.0" },
|
||||
@@ -512,6 +514,28 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/77/3c/aa88abe01f3be3d1f8f787d1d33dc83e76fec05945f9a28fbb41cfb99cd5/nvidia_cublas_cu12-12.9.1.4-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:453611eb21a7c1f2c2156ed9f3a45b691deda0440ec550860290dc901af5b4c2", size = 581242350, upload-time = "2025-06-05T20:04:51.979Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "onnx-asr"
|
||||
version = "0.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/78/f6/b154881761a593312f509522f99542acffa2516f7a1df6ddf5660ad4a162/onnx_asr-0.11.0.tar.gz", hash = "sha256:57ad8d9571dc17db95f0daf9ba432b9472383de320c610735850e56b5375a37d", size = 43665, upload-time = "2026-03-23T02:30:57.349Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/04/bdffd682cc38b43144b6528186c80451f219a05e3fd0eb331a548f455b9a/onnx_asr-0.11.0-py3-none-any.whl", hash = "sha256:142d8b3ce7716684992826a269304f5ce9cf1c0fe704b751358e223f45d2a5cf", size = 138349, upload-time = "2026-03-23T02:30:58.566Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
cpu = [
|
||||
{ name = "onnxruntime" },
|
||||
]
|
||||
hub = [
|
||||
{ name = "huggingface-hub" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "onnxruntime"
|
||||
version = "1.24.3"
|
||||
|
||||
Reference in New Issue
Block a user