Compare commits
72
Commits
ec6d415114
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b40e2b4c1 | ||
|
|
b315f60746 | ||
|
|
50787c6531 | ||
|
|
328580abfb | ||
|
|
63307fed01 | ||
|
|
6f1064ccc4 | ||
|
|
f7c0ed9a8c | ||
|
|
a8241b1d6b | ||
|
|
0f8ecb5bd3 | ||
|
|
e22b2f1bae | ||
|
|
4dea7f4bc6 | ||
|
|
343a31b35b | ||
|
|
a8b8a8c0c9 | ||
|
|
453b5d9dfa | ||
|
|
99ed5ed92f | ||
|
|
937eafe8db | ||
|
|
2b54f5bc9e | ||
|
|
671ac8e5c9 | ||
|
|
3860f52eb9 | ||
|
|
f3d0293120 | ||
|
|
78960120fd | ||
|
|
b8bb78103a | ||
|
|
4c4a8be07b | ||
|
|
d71aec4ab2 | ||
|
|
0cd105927b | ||
|
|
16e5372f2d | ||
|
|
7d672a7dc8 | ||
|
|
2e30d668ce | ||
|
|
ef61ced2e5 | ||
|
|
5ec9c77dfa | ||
|
|
78f2a8aecc | ||
|
|
e71b2d1668 | ||
|
|
0f67da8acf | ||
|
|
28d0f37b82 | ||
|
|
fa9908af6f | ||
|
|
f59efa51b7 | ||
|
|
cca09aedde | ||
|
|
b2a2d7d7da | ||
|
|
15f88b1de8 | ||
|
|
89475b9b97 | ||
|
|
312153f4bc | ||
|
|
249b970cc9 | ||
|
|
38c99855e2 | ||
|
|
68fda7ee89 | ||
|
|
42f094ccb9 | ||
|
|
6345cb6449 | ||
|
|
0ea4d56c9d | ||
|
|
bd0d7e6736 | ||
|
|
7dc5723742 | ||
|
|
18d1094d10 | ||
|
|
558c1baf89 | ||
|
|
fde4c48859 | ||
|
|
e7c6eacb56 | ||
|
|
d525f5b18a | ||
|
|
a53344dc04 | ||
|
|
13314f9334 | ||
|
|
66fc264f2d | ||
|
|
9fc2dc0c6c | ||
|
|
6f0adf2eb0 | ||
|
|
bcf264e0aa | ||
|
|
2fc777fdee | ||
|
|
b09036eaa3 | ||
|
|
5c6697ecbf | ||
|
|
bfabf09c83 | ||
|
|
f5c1cb8090 | ||
|
|
e3c9040bfe | ||
|
|
5b5fd535d6 | ||
|
|
1e14f2687a | ||
|
|
95f295c37c | ||
|
|
d43c3b7c9a | ||
|
|
b412b73fc0 | ||
|
|
f966d4554b |
No files matched your search
+3
-1
@@ -5,4 +5,6 @@ __pycache__/
|
||||
dist/
|
||||
*.pyc
|
||||
.codex
|
||||
.qwen/
|
||||
.qwen/
|
||||
tmp
|
||||
/.scratch/
|
||||
@@ -5,6 +5,11 @@
|
||||
Не часть пакета: они опираются на `sherpa-onnx`, которого нет в зависимостях
|
||||
проекта, и живут в `.scratch/`, а не в `src/`.
|
||||
|
||||
Код этого каталога временно закреплен в Git: его используют несколько задач на
|
||||
нескольких компьютерах, пока идет исследование диаризации. Общее правило
|
||||
`/.scratch/` относится к новым локальным материалам; уже отслеживаемые файлы
|
||||
этого стенда продолжают версионироваться.
|
||||
|
||||
Результаты первого прогона описаны в
|
||||
[разведочном замере](../../docs/benchmarks/2026-08-12-diarization-feasibility.md).
|
||||
|
||||
|
||||
@@ -25,11 +25,12 @@ Package manager is **uv** (not pip). Build backend is hatchling.
|
||||
|
||||
```
|
||||
CLI (cli.py)
|
||||
→ config.py cascade: CLI arg → .transcriber.toml → device-aware default → hardcoded
|
||||
→ config.py cascade: CLI arg → .transcriber.toml (model/compute_type stay None for the module to resolve)
|
||||
→ 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
|
||||
→ transcriber.py Transcriber(ExecutionRequest): resolves auto + device defaults,
|
||||
get_backend(device) → ensure_model_available → create_model once per run,
|
||||
transcribe(file) per file; fallback and ExecutionInfo stay inside
|
||||
→ formatter.py segments → markdown with timestamps, paragraph grouping (>2s pause or >60s)
|
||||
```
|
||||
|
||||
@@ -47,19 +48,21 @@ Three backends implement the `Backend` Protocol (structural typing, no inheritan
|
||||
|
||||
### 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).
|
||||
- **Execution module** (`Transcriber`): one owner per run for model handle, adapter, actual device and allowed GPU→CPU fallback (load time and mid-stream). Callers get `TranscribeResult` + `ExecutionInfo` (requested vs actual device, engine, model, compute type, threads, `runtime_info()` from the adapter for `--verbose`). Fallback is reachable only with `strict_device=False` (Python API): CLI explicit device is strict, `auto` is ONNX CPU.
|
||||
- **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`.
|
||||
- **Batch mode**: 3-phase pipeline (prescan → create `Transcriber` → transcribe all). No state is carried between files by the CLI.
|
||||
- **Device-aware defaults**: `compute_type` and `model` vary by device (float16 for CUDA, int8 for OpenVINO, float32 for CPU). Defined in `config.py` `DEVICE_DEFAULTS`, applied by `Transcriber` when the request leaves them `None`.
|
||||
- **ONNX thread budget**: `--threads` sets `intra_op_num_threads` for both ASR and VAD sessions via `SessionOptions`; 0 keeps onnxruntime defaults.
|
||||
- **OpenVINO uses pre-quantized models** — `compute_type` selects which HF repo to download, not a runtime parameter.
|
||||
|
||||
## 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
|
||||
- CLI tests: `typer.testing.CliRunner` with the real `Transcriber` and a fake adapter patched at `local_transcriber.transcriber.get_backend`; `load_config`, `validate_input_file`, `write_transcript` mocked
|
||||
- `_cli_run()` — context manager assembling that standard set, yields `(backend, write_transcript)`
|
||||
- `_make_result()` / `_make_backend()` — factories for test data
|
||||
- Module tests (`test_transcriber.py`): `Transcriber` through its interface with `_make_run_backend()`; adapter tests patch the libraries (`onnx_asr.load_model`, `faster_whisper.WhisperModel`)
|
||||
|
||||
## Common tasks
|
||||
|
||||
@@ -79,7 +82,7 @@ All tests mock backends — no real model downloads or transcription. Key test p
|
||||
|
||||
### Issue tracker
|
||||
|
||||
Задачи ведутся в Gitea через `tea`; GitHub используется только как зеркало, внешние PR не входят в triage. См. `docs/agents/issue-tracker.md`.
|
||||
Задачи ведутся в Gitea через `tea`; внешние PR не входят в triage. См. `docs/agents/issue-tracker.md`.
|
||||
|
||||
### Triage labels
|
||||
|
||||
|
||||
@@ -1 +1,21 @@
|
||||
@AGENTS.md
|
||||
@AGENTS.md
|
||||
|
||||
# Стиль изложения
|
||||
|
||||
Пиши легко для чтения. Сокращай количество мыслей, а не количество слов, которыми выражена каждая мысль.
|
||||
Please remove all mannered prose.
|
||||
|
||||
# Выбрать подход и держаться его
|
||||
|
||||
Выбирая, как решать задачу, возьми один подход и доведи его до конца. Не пересматривай
|
||||
решение, пока не появится новая информация, которая прямо противоречит твоим
|
||||
рассуждениям. Если колеблешься между двумя подходами, выбери один и посмотри, куда он
|
||||
приведёт; свернуть можно позже, если выбранный не сработал.
|
||||
|
||||
# Для моделей Fable 5 и 5.1
|
||||
|
||||
При прочих равных экономь токены на правке файлов: когда результат не меняется, правь
|
||||
файл точечно, а не переписывай целиком.
|
||||
|
||||
Сначала про себя составь список того, что нужно дальше; затем в одном ответе запроси
|
||||
всё, что не зависит от результата другого запроса.
|
||||
+8
-8
@@ -43,25 +43,25 @@ tests/
|
||||
CLI (cli.py)
|
||||
→ config.py: загрузка .transcriber.toml, каскад дефолтов
|
||||
→ utils.py: валидация файлов, определение устройства
|
||||
→ transcriber.py: загрузка модели (с CUDA fallback), транскрипция
|
||||
→ transcriber.py: Transcriber — разрешение устройства, загрузка модели один раз на запуск, транскрипция, fallback
|
||||
→ formatter.py: сегменты → markdown с таймкодами
|
||||
→ запись результата
|
||||
```
|
||||
|
||||
## Ключевые архитектурные решения
|
||||
|
||||
- **CUDA fallback** — двухуровневый: при загрузке модели и при транскрипции (mid-stream). GPU может быть видна через nvidia-smi, но не иметь достаточно VRAM.
|
||||
- **Device-aware дефолты** — `compute_type` зависит от устройства (`float16`/`float32`). `float16` не работает на CPU, `float32` расточителен на GPU.
|
||||
- **Module выполнения** (`Transcriber` в `transcriber.py`) — один владелец модели, adapter'а и фактического исполнения на запуск. Fallback GPU→CPU (при загрузке и mid-stream) живёт внутри и доступен только при `strict_device=False` из Python API: явный device в CLI strict, `auto` — ONNX CPU.
|
||||
- **Device-aware дефолты** — `model` и `compute_type` без явного значения разрешаются по устройству при загрузке (`float16`/`float32`). `float16` не работает на CPU, `float32` расточителен на GPU.
|
||||
- **cuBLAS bootstrap** (`_cuda_bootstrap.py`) — preload через ctypes до импорта ctranslate2. pip-пакет `nvidia-cublas-cu12` ставит `.so` в нестандартное место, а `LD_LIBRARY_PATH` нельзя изменить в рантайме.
|
||||
- **Батч-режим** — 3 фазы (prescan → load model → transcribe). Модель загружается один раз (~2-5 сек), невалидные файлы отсеиваются до загрузки.
|
||||
- **Батч-режим** — 3 фазы (prescan → создание `Transcriber` → transcribe). Модель загружается один раз (~2-5 сек), невалидные файлы отсеиваются до загрузки; CLI не переносит состояние между файлами.
|
||||
- **Ручной glob в utils** — typer на Windows не раскрывает `*.mp4`, поэтому глобы обрабатываются явно.
|
||||
|
||||
## Тестирование
|
||||
|
||||
- Все CLI-тесты через `typer.testing.CliRunner` + моки (faster-whisper не вызывается)
|
||||
- Моки: `load_config`, `validate_input_file`, `detect_device`, `ensure_model_available`, `load_model`, `_transcribe_file`, `write_transcript`
|
||||
- Паттерн: `_single_patches()` — хелпер для стандартного happy-path набора моков
|
||||
- `_make_result()` / `_make_tfr()` — фабрики тестовых данных
|
||||
- Все CLI-тесты через `typer.testing.CliRunner` с настоящим `Transcriber` и fake adapter, подменённым в `local_transcriber.transcriber.get_backend` (библиотеки не вызываются)
|
||||
- Моки: `load_config`, `validate_input_file`, `write_transcript`
|
||||
- Паттерн: `_cli_run()` — контекстный менеджер стандартного набора, отдаёт `(backend, write_transcript)`
|
||||
- `_make_result()` / `_make_backend()` — фабрики тестовых данных
|
||||
|
||||
## Частые задачи
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ transcribe meeting.mp4
|
||||
```
|
||||
|
||||
- **Полностью локально** — данные не покидают машину
|
||||
- **Авто-ускорение** — NVIDIA CUDA при наличии GPU, иначе ONNX на CPU
|
||||
- **ONNX на CPU по умолчанию** — CUDA и OpenVINO включаются явно
|
||||
- **Батч-режим** — обработка нескольких файлов за один вызов
|
||||
- **Разделение говорящих** — локальная диаризация по флагу `--diarize`
|
||||
- **Из проводника Windows** — пункт Transcribe в меню «Отправить» ([установка](#контекстное-меню-проводника-windows))
|
||||
@@ -26,26 +26,42 @@ curl -LsSf https://astral.sh/uv/install.sh | sh # Linux / macOS
|
||||
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" # Windows
|
||||
```
|
||||
|
||||
**2. Установить transcriber:**
|
||||
После установки откройте новую консоль, чтобы перечитались переменные окружения.
|
||||
|
||||
**2. Установить transcriber.** Напрямую из git, без скачивания исходников:
|
||||
|
||||
```bash
|
||||
uv tool install git+https://git.dementev.space/ddmitry/local-transcriber.git
|
||||
uv tool install --python 3.13 git+https://github.com/dementev-dev/local-transcriber
|
||||
```
|
||||
|
||||
**3. Ускорение (ставится автоматически):**
|
||||
Или из клона репозитория (`<URL>` — адрес репозитория, из которого вы ставите):
|
||||
|
||||
- **NVIDIA CUDA** (GPU): если есть GPU — транскрипция в 5-10× быстрее
|
||||
- **Windows**: `winget install -e --id Nvidia.CUDA --version 12.9` (от администратора), перезапустить терминал
|
||||
- **Linux / WSL2**: работает из коробки (нужен только драйвер: `nvidia-smi`)
|
||||
- **Без NVIDIA GPU**: автоматически используется ONNX с GigaAM RNN-T на CPU.
|
||||
Эта модель понимает только русскую речь
|
||||
- **Другие языки без NVIDIA**: выбирайте Whisper явно —
|
||||
`--device openvino-cpu --model medium` на x86 или `--device cpu --model medium`
|
||||
на любой платформе
|
||||
- **OpenVINO** для Intel GPU или x86 CPU остаётся доступен через явный
|
||||
`--device openvino`, `--device openvino-gpu` или `--device openvino-cpu`
|
||||
```bash
|
||||
git clone <URL>
|
||||
cd local-transcriber
|
||||
uv tool install --python 3.13 .
|
||||
```
|
||||
|
||||
**4. Готово:**
|
||||
**3. Пункт `Transcribe` в меню «Отправить»** (Windows, по желанию):
|
||||
|
||||
```bash
|
||||
transcribe --install-menu
|
||||
```
|
||||
|
||||
Подробнее — в разделе [контекстное меню проводника](#контекстное-меню-проводника-windows).
|
||||
|
||||
**4. Выбор исполнения:**
|
||||
|
||||
- По умолчанию используется ONNX с GigaAM RNN-T на CPU, независимо от наличия
|
||||
NVIDIA. Эта модель понимает только русскую речь.
|
||||
- Для других языков выбирайте Whisper явно:
|
||||
`--device openvino-cpu --model medium` на x86 или `--device cpu --model medium`.
|
||||
- OpenVINO для Intel GPU или x86 CPU доступен через `--device openvino`,
|
||||
`--device openvino-gpu` или `--device openvino-cpu`.
|
||||
- Для NVIDIA установите дополнительные библиотеки и выберите CUDA явно:
|
||||
[подключение CUDA](#подключение-cuda).
|
||||
|
||||
**5. Готово:**
|
||||
|
||||
```bash
|
||||
transcribe meeting.mp4
|
||||
@@ -63,7 +79,14 @@ transcribe meeting.mp4
|
||||
**Обновление:**
|
||||
|
||||
```bash
|
||||
uv tool install --force git+https://git.dementev.space/ddmitry/local-transcriber.git
|
||||
uv tool install --python 3.13 --force git+https://github.com/dementev-dev/local-transcriber
|
||||
```
|
||||
|
||||
Или в папке со склонированным репозиторием:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
uv tool install --python 3.13 --force .
|
||||
```
|
||||
|
||||
**Удаление:**
|
||||
@@ -107,17 +130,74 @@ HuggingFace Hub использует симлинки для экономии м
|
||||
|
||||
</details>
|
||||
|
||||
## Подключение CUDA
|
||||
|
||||
Обычная установка не требует NVIDIA-пакетов. Extra `cuda` добавляет cuBLAS
|
||||
в окружение приложения на Linux/WSL x86_64 и Windows x64. Полный системный
|
||||
CUDA Toolkit и ручная правка системного PATH для этого пути не нужны.
|
||||
Совместимые GPU и драйвер NVIDIA необходимы отдельно: extra не устанавливает
|
||||
драйвер и не исправляет несовместимость старой видеокарты.
|
||||
|
||||
Для установки из Git или подключения CUDA к уже установленной программе:
|
||||
|
||||
```bash
|
||||
uv tool install --python 3.13 --force "local-transcriber[cuda] @ git+https://github.com/dementev-dev/local-transcriber"
|
||||
```
|
||||
|
||||
Из клона репозитория:
|
||||
|
||||
```bash
|
||||
uv tool install --python 3.13 --force ".[cuda]"
|
||||
```
|
||||
|
||||
Для локальной разработки:
|
||||
|
||||
```bash
|
||||
uv sync --extra cuda
|
||||
uv run --extra cuda transcribe meeting.mp4 --device cuda
|
||||
```
|
||||
|
||||
После установки extra выберите CUDA при запуске:
|
||||
|
||||
```bash
|
||||
transcribe meeting.mp4 --device cuda
|
||||
```
|
||||
|
||||
Либо задайте в `.transcriber.toml`:
|
||||
|
||||
```toml
|
||||
device = "cuda"
|
||||
```
|
||||
|
||||
Установка extra сама по себе не меняет `auto`: он всегда использует ONNX CPU.
|
||||
Явная CUDA при ошибке завершает обработку с исходной причиной; для распознанных
|
||||
причин выводится подсказка. Молчаливого перехода на CPU нет. В батче ошибка
|
||||
учитывается для каждого файла.
|
||||
|
||||
На Windows нужен также [Visual C++ Runtime x64](https://aka.ms/vs/17/release/vc_redist.x64.exe).
|
||||
Состав DLL проверен для закреплённых версий CTranslate2 и cuBLAS;
|
||||
реальный прогон нового пути на Windows без Toolkit пока не выполнен.
|
||||
Подробности и ограничения проверки — [ADR-001](docs/adr/001-cuda-bootstrap.md).
|
||||
|
||||
При обновлении из старой установки:
|
||||
|
||||
- Для CPU повторите обычную команду `uv tool install` с `--force` без `[cuda]`.
|
||||
В окружении разработки выполните `uv sync` — прежняя обязательная cuBLAS будет удалена.
|
||||
- Для CUDA повторите соответствующую команду выше. Старый автоматический выбор
|
||||
по `nvidia-smi` больше не действует; сохраните `device = "cuda"` в конфиге
|
||||
либо передавайте `--device cuda`.
|
||||
|
||||
## Использование
|
||||
|
||||
```bash
|
||||
# Простой запуск (CUDA medium или ONNX GigaAM RNN-T, язык ru)
|
||||
# Простой запуск (ONNX GigaAM RNN-T на CPU, язык ru)
|
||||
transcribe meeting.mp4
|
||||
|
||||
# Указать язык
|
||||
transcribe lecture.mp3 --language en
|
||||
transcribe lecture.mp3 --device cpu --model medium --language en
|
||||
|
||||
# Максимальное качество на NVIDIA GPU
|
||||
transcribe podcast.wav --model large-v3 --compute-type float16
|
||||
transcribe podcast.wav --device cuda --model large-v3 --compute-type float16
|
||||
|
||||
# Максимальное качество на Intel GPU
|
||||
transcribe podcast.wav --model large-v3 --device openvino-gpu
|
||||
@@ -218,15 +298,32 @@ transcribe --uninstall-menu
|
||||
| Опция | Сокращение | По умолчанию | Описание |
|
||||
|-------|-----------|-------------|----------|
|
||||
| `--model` | `-m` | medium (CUDA) / gigaam-v3-e2e-rnnt (ONNX) | Модель распознавания |
|
||||
| `--language` | `-l` | `ru` | Язык (ru, en, auto и др.); автоматический профиль без NVIDIA понимает только русский |
|
||||
| `--language` | `-l` | `ru` | Язык (ru, en, auto и др.); автоматический профиль понимает только русский |
|
||||
| `--output` | `-o` | `<файл>-transcript.md` | Путь к выходному файлу |
|
||||
| `--device` | `-d` | `auto` | Устройство (auto, cpu, cuda, openvino, openvino-gpu, openvino-cpu, onnx) |
|
||||
| `--compute-type` | — | float16 (CUDA) / int8 (ONNX/OpenVINO) / float32 (CPU) | Тип вычислений |
|
||||
| `--threads` | `-t` | 0 (авто) | Потоки CPU (рекомендуется = число физ. ядер) |
|
||||
| `--diarize` | — | — | Разделить текст на реплики говорящих |
|
||||
| `--speakers` | — | авто | Ожидаемое число говорящих; включает `--diarize` |
|
||||
| `--threads` | `-t` | 0 (авто) | Потоки CPU (рекомендуется = число физ. ядер); для ONNX задаёт потоки и ASR, и VAD |
|
||||
| `--diarize` / `--no-diarize` | — | `false` | Включить или отключить разделение на реплики говорящих |
|
||||
| `--speakers` | — | авто | Ожидаемое число говорящих; включает диаризацию и несовместим с `--no-diarize` |
|
||||
| `--force` | `-f` | — | Перезаписать существующие транскрипты |
|
||||
| `--verbose` | `-v` | — | Подробный вывод |
|
||||
| `--verbose` | `-v` | — | Подробный вывод: сегменты по ходу, движок, потоки, версии runtime |
|
||||
|
||||
### Диагностика исполнения
|
||||
|
||||
Перед распознаванием программа печатает модель, устройство и тип вычислений,
|
||||
которые выбраны фактически: после `auto` это `onnx`, после `openvino` —
|
||||
`openvino-gpu` или `openvino-cpu`. Если после загрузки устройство оказалось
|
||||
не тем, что выбрано до неё (например, OpenVINO взял CPU вместо GPU),
|
||||
выводится строка `Запрошено ..., используется ...`.
|
||||
|
||||
С `--verbose` добавляются движок, бюджет потоков и версии runtime: для ONNX —
|
||||
`onnxruntime`, `onnx-asr`, доступные providers и те, что заданы сессиям ASR
|
||||
и VAD; для CUDA — `ctranslate2` и число видимых CUDA-устройств; для OpenVINO —
|
||||
версия и доступные устройства. Этих строк достаточно, чтобы разобрать проблему
|
||||
на чужой машине без доступа к записи: попросите прислать вывод с `--verbose`.
|
||||
|
||||
Наличие provider в списке доступных не означает, что распознавание идёт
|
||||
на GPU или NPU: сессии получают только явно заданный список, сейчас это CPU.
|
||||
|
||||
## Платформы
|
||||
|
||||
@@ -235,7 +332,7 @@ transcribe --uninstall-menu
|
||||
| CPU через ONNX | ✅ авто | ✅ авто | ✅ авто |
|
||||
| OpenVINO (x86 CPU) | ✅ явно | — | ✅ явно |
|
||||
| OpenVINO (Intel GPU) | ✅ явно | — | ✅ явно |
|
||||
| GPU (NVIDIA) | ✅ авто | — | ✅ (нужен CUDA 12) |
|
||||
| GPU (NVIDIA) | extra + явно (x86_64) | — | extra + явно (x64; прогон без Toolkit ожидается) |
|
||||
|
||||
Данные по macOS основаны на доступности пакетов onnxruntime: прогонов на этой
|
||||
платформе не было.
|
||||
@@ -244,9 +341,9 @@ transcribe --uninstall-menu
|
||||
<summary>Linux / WSL2</summary>
|
||||
|
||||
- **Intel GPU** (Arc, встроенная графика) работает из коробки через OpenVINO
|
||||
- **NVIDIA GPU** работает из коробки — cuBLAS ставится автоматически как зависимость
|
||||
- Нужен только драйвер NVIDIA (проверка: `nvidia-smi`)
|
||||
- На ARM (aarch64) cuBLAS через pip недоступен — нужен системный CUDA toolkit
|
||||
- **NVIDIA GPU**: [extra `cuda` и явный выбор](#подключение-cuda), отдельно совместимый драйвер
|
||||
- `nvidia-smi` проверяет наличие драйвера, но не доказывает совместимость GPU с runtime
|
||||
- CUDA extra на ARM (aarch64) этим проектом не поддерживается
|
||||
|
||||
</details>
|
||||
|
||||
@@ -263,13 +360,9 @@ transcribe --uninstall-menu
|
||||
|
||||
- CPU работает из коробки
|
||||
- **Intel GPU** (Arc, встроенная графика) работает из коробки через OpenVINO
|
||||
- Для **NVIDIA GPU** нужен **CUDA 12** (ctranslate2 4.7 не совместим с CUDA 11 и 13):
|
||||
```
|
||||
winget install -e --id Nvidia.CUDA --version 12.9
|
||||
```
|
||||
> `winget install` требует запуска от имени администратора (elevated terminal).
|
||||
> `uv tool install` работает без админа (ставит в пользовательскую директорию).
|
||||
- После установки CUDA перезапустите терминал
|
||||
- **NVIDIA GPU**: [extra `cuda` и явный выбор](#подключение-cuda), отдельно совместимый драйвер
|
||||
- Требуется Visual C++ Runtime x64; системный CUDA Toolkit не нужен для extra
|
||||
- Реальная транскрипция с extra без Toolkit пока не проверена на Windows
|
||||
|
||||
</details>
|
||||
|
||||
@@ -282,6 +375,7 @@ device = "openvino-cpu"
|
||||
model = "large-v3-turbo"
|
||||
compute_type = "int8"
|
||||
language = "ru"
|
||||
diarize = true
|
||||
```
|
||||
|
||||
Порядок поиска:
|
||||
@@ -289,9 +383,11 @@ language = "ru"
|
||||
2. `~/.config/transcriber/config.toml`
|
||||
|
||||
Приоритет: **CLI-аргумент > конфиг > device-aware дефолт > встроенный дефолт**.
|
||||
`--diarize` и `--no-diarize` позволяют переопределить `diarize` из конфига для
|
||||
отдельного запуска.
|
||||
|
||||
При `device = "auto"` выбирается CUDA, если доступен `nvidia-smi`, иначе ONNX.
|
||||
Явный `device` из CLI или конфига отключает этот автоматический выбор.
|
||||
При `device = "auto"` всегда выбирается ONNX CPU, даже если доступны
|
||||
`nvidia-smi` и extra `cuda`. Другие варианты выбираются явно из CLI или конфига.
|
||||
Каталоги моделей различаются между бэкендами, поэтому при закреплении `model`
|
||||
в конфиге рекомендуется явно закрепить и совместимый `device`. То же с языком:
|
||||
автоматический ONNX-профиль рассчитан на русскую речь, а для остальных языков
|
||||
@@ -308,9 +404,9 @@ language = "ru"
|
||||
## Модели и GPU
|
||||
|
||||
Рекомендации:
|
||||
- **По умолчанию без CUDA:** ONNX `gigaam-v3-e2e-rnnt` — читаемый русский текст с
|
||||
- **По умолчанию:** ONNX `gigaam-v3-e2e-rnnt` — читаемый русский текст с
|
||||
пунктуацией почти без потери скорости относительно сырого `gigaam-v3`
|
||||
- **Макс. качество (NVIDIA):** `large-v3` + `--compute-type float16`
|
||||
- **Макс. качество (NVIDIA):** `--device cuda --model large-v3 --compute-type float16`
|
||||
- **Макс. качество (Intel GPU):** `large-v3` + `--device openvino-gpu`
|
||||
- **Макс. скорость CPU (русский):** `--device onnx --model gigaam-v3` (17-29× RTF, без пунктуации; рекомендуется LLM-нормализация терминов после)
|
||||
- **Быстрый OpenVINO с низким WER:** `--device openvino-cpu --model large-v3-turbo --compute-type int8` (7,2× RTFx на контрольном Intel CPU; пунктуация может быть слабой)
|
||||
@@ -353,7 +449,8 @@ faster-whisper её не поддерживает, и запуск с `--device
|
||||
| `parakeet-v3` | ~600 MB | 7,6×* | 25 языков | ✅ |
|
||||
|
||||
\* Наблюдение на AMD Ryzen 7 8845H, Windows, `int8`, три записи общей
|
||||
длительностью 43:37. Это не приёмочный замер для целевого Intel Core i5.
|
||||
длительностью 43:37. Замер описывает только эту тестовую машину; скорость на
|
||||
других CPU требует отдельного прогона.
|
||||
Методика и качественное сравнение:
|
||||
[benchmark GigaAM и Whisper](docs/benchmarks/2026-08-11-gigaam-model-comparison.md).
|
||||
|
||||
@@ -413,10 +510,10 @@ device-aware дефолт недоступен для выбранной мод
|
||||
# Транскрипт: meeting.mp4
|
||||
|
||||
- **Дата транскрипции**: 2026-03-17 14:30:05
|
||||
- **Модель**: large-v3
|
||||
- **Модель**: gigaam-v3-e2e-rnnt
|
||||
- **Язык**: ru (задан явно)
|
||||
- **Длительность**: 01:23:45
|
||||
- **Устройство**: CUDA (NVIDIA GeForce RTX 3060)
|
||||
- **Устройство**: ONNX (CPU)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+9
-9
@@ -49,7 +49,7 @@ transcribe <путь_к_файлу> [опции]
|
||||
--output, -o Путь к выходному файлу
|
||||
По умолчанию: <input_stem>-transcript.md
|
||||
--device, -d Устройство (auto|cpu|cuda|onnx|openvino|openvino-gpu|openvino-cpu)
|
||||
По умолчанию: auto (CUDA при наличии, иначе ONNX CPU)
|
||||
По умолчанию: auto (всегда ONNX CPU)
|
||||
--compute-type Тип вычислений
|
||||
По умолчанию: float16 (CUDA) / int8 (ONNX)
|
||||
--verbose, -v Подробный вывод (прогресс сегментов)
|
||||
@@ -63,10 +63,10 @@ transcribe <путь_к_файлу> [опции]
|
||||
# Транскрипт: meeting-2026-03-17.mp4
|
||||
|
||||
- **Дата транскрипции**: 2026-03-17 14:30:05
|
||||
- **Модель**: large-v3
|
||||
- **Язык**: ru (задан явно) / ru (определён автоматически)
|
||||
- **Модель**: gigaam-v3-e2e-rnnt
|
||||
- **Язык**: ru (задан явно)
|
||||
- **Длительность**: 01:23:45
|
||||
- **Устройство**: CUDA (NVIDIA GeForce RTX 3060)
|
||||
- **Устройство**: ONNX (CPU)
|
||||
|
||||
---
|
||||
|
||||
@@ -103,7 +103,7 @@ transcribe <путь_к_файлу> [опции]
|
||||
|---------------------------|---------------------------------------|
|
||||
| RTX 3060 + large-v3 | ~10-15x (1 час аудио ≈ 4-6 мин) |
|
||||
| RTX 4050 + large-v3 | ~12-18x (1 час аудио ≈ 3-5 мин) |
|
||||
| Quadro M3000M + large-v3 | ~3-5x (1 час аудио ≈ 12-20 мин) |
|
||||
| Quadro M3000M + CUDA | Не поддерживается текущей сборкой; см. [совместимость](gpu.md#совместимость-gpu) |
|
||||
| CPU + ONNX GigaAM RNN-T | ~10-14x (1 час аудио ≈ 4-6 мин) |
|
||||
| CPU + OpenVINO Turbo INT8 | ~7-10x (1 час аудио ≈ 6-9 мин) |
|
||||
| CPU (modern) + large-v3 | ~0.5-1x (1 час аудио ≈ 60-120 мин) |
|
||||
@@ -115,7 +115,7 @@ transcribe <путь_к_файлу> [опции]
|
||||
|-------------------|-------|--------------------------|----------------------------|-------------------------|
|
||||
| RTX 3060 | 6 GB | ✅ | ✅ (впритык) | int8 — безопасный выбор |
|
||||
| RTX 4050 | 6 GB | ✅ | ✅ (впритык) | int8 — безопасный выбор |
|
||||
| Quadro M3000M | 4 GB | ✅ | ⚠️ может OOM | int8 обязательно |
|
||||
| Quadro M3000M | 4 GB | ❌ | ❌ | ONNX CPU или OpenVINO |
|
||||
| Без GPU | — | ONNX GigaAM INT8 | — | auto выбирает ONNX |
|
||||
|
||||
Device-aware дефолты выбирают `float16` для CUDA и `int8` для ONNX/OpenVINO.
|
||||
@@ -123,7 +123,7 @@ Device-aware дефолты выбирают `float16` для CUDA и `int8` д
|
||||
### 4.2. Требования к окружению
|
||||
|
||||
- Python ≥ 3.13
|
||||
- Для GPU: Linux/WSL2 — cuBLAS из nvidia-cublas-cu12 (ставится автоматически через `uv sync`); Windows — системный CUDA toolkit (см. ADR-001)
|
||||
- Для NVIDIA GPU на Linux/WSL x86_64 и Windows x64: совместимый драйвер, extra `cuda` и явный выбор CUDA (см. ADR-001); Windows также требует Visual C++ Runtime x64
|
||||
- Дисковое пространство для моделей: зависит от выбранного backend и модели
|
||||
- Выходные файлы: UTF-8 (явная кодировка при записи)
|
||||
|
||||
@@ -177,9 +177,9 @@ local-transcriber/
|
||||
| Качество распознавания русского текста | Среднее | large-v3 хорошо справляется с ru; при проблемах — попробовать `--language ru` вместо auto |
|
||||
| Нет разделения по спикерам | Низкое | Осознанно выведено за скоуп MVP; добавление diarization (pyannote.audio) — возможное расширение |
|
||||
| Первый запуск: долгая загрузка модели | Низкое | Прогресс-бар при скачивании; модели кешируются в `~/.cache/huggingface/` |
|
||||
| CUDA несовместимость на Windows | Среднее | Автоматический fallback на CPU + предупреждение; в README — инструкция по CUDA |
|
||||
| CUDA несовместимость на Windows | Среднее | Явная CUDA сохраняет причину ошибки; пользователь выбирает CPU или исправляет окружение по README |
|
||||
| Большие файлы (>2 часов) | Среднее | Учитывать память выбранного backend; чанкование рассматривается отдельно |
|
||||
| OOM на GPU с 4 GB VRAM | Среднее | CUDA использует float16; при OOM — fallback на CPU с предупреждением или явный более лёгкий профиль |
|
||||
| OOM на GPU с 4 GB VRAM | Среднее | CUDA использует float16; при OOM явной CUDA — ошибка с подсказкой выбрать меньшую модель или CPU |
|
||||
| Файл без речи (тишина, музыка, шум) | Низкое | Создаётся транскрипт с шапкой метаданных и `*Речь не обнаружена.*` в теле + предупреждение в stderr |
|
||||
|
||||
## 7. Вне скоупа MVP
|
||||
|
||||
+108
-52
@@ -1,78 +1,134 @@
|
||||
# ADR-001: Preload libcublas из pip-пакета для GPU на Linux/WSL2
|
||||
# ADR-001: Необязательная CUDA и загрузка cuBLAS из окружения
|
||||
|
||||
**Статус**: Принято
|
||||
**Статус**: Принято; пересмотр обязательной cuBLAS от 2026-03-18
|
||||
**Дата**: 2026-03-18
|
||||
**Обновлено**: 2026-09-21, задача [#2](https://git.dementev.space/ddmitry/local-transcriber/issues/2)
|
||||
|
||||
## Контекст
|
||||
|
||||
ctranslate2 (backend faster-whisper) в runtime делает `dlopen("libcublas.so.12")`,
|
||||
но не бандлит эту библиотеку в свой wheel — ожидает её в системе.
|
||||
Без установленного CUDA toolkit `uv run transcribe --device cuda` падает с ошибкой.
|
||||
CTranslate2 загружает cuBLAS динамически и не поставляет её в своём wheel.
|
||||
Прежняя обязательная зависимость добавляла около 581 MB скачивания на Linux
|
||||
x86_64, даже если пользователь работал только через ONNX CPU.
|
||||
Наличие `nvidia-smi` доказывает наличие утилиты драйвера, но не совместимость GPU
|
||||
с runtime. Автоматический выбор CUDA мог приводить к ошибке на старом GPU.
|
||||
|
||||
Ключевые факты:
|
||||
- `libcuda.so.1` приходит от NVIDIA driver (всегда есть, если GPU есть)
|
||||
- `libcublas.so.12` отсутствует в wheel ctranslate2 на обеих платформах
|
||||
- **cuDNN не нужен** — в `libctranslate2.so` ноль символов cudnn (проверено `nm -D` и `strings`)
|
||||
- На Windows ctranslate2 делает `os.add_dll_directory` в своём `__init__.py`,
|
||||
но только для DLL внутри пакета; `cublas64_12.dll` тоже не бандлится
|
||||
Прежнее утверждение о недоступности Windows wheel `nvidia-cublas-cu12` было
|
||||
ошибочным: такой wheel есть и для закреплённой версии 12.9.1.4. Следовательно,
|
||||
системный CUDA Toolkit не должен быть обязательным способом подключения Windows.
|
||||
|
||||
## Решение
|
||||
|
||||
### nvidia-cublas-cu12 как pip-зависимость
|
||||
CUDA подключается через extra `cuda` на Linux/WSL x86_64 и Windows x64.
|
||||
Драйвер NVIDIA остаётся внешним требованием; Windows также требует Visual C++
|
||||
Runtime x64. Установка extra не включает CUDA: пользователь выбирает её через
|
||||
CLI или TOML. `auto` всегда выбирает ONNX CPU, ASR и VAD получают явный
|
||||
`CPUExecutionProvider`. Команды установки и обновления — в
|
||||
[README](../../README.md#подключение-cuda).
|
||||
|
||||
В `pyproject.toml`:
|
||||
```
|
||||
"nvidia-cublas-cu12>=12.4; sys_platform == 'linux' and platform_machine == 'x86_64'"
|
||||
```
|
||||
CTranslate2 4.8.1 и cuBLAS 12.9.1.4 закреплены в `pyproject.toml`, чтобы
|
||||
установка через `uv tool` и из клона использовала проверенное сочетание.
|
||||
В cuBLAS 12.9.2.10 появилась дополнительная зависимость NVRTC. Обновление
|
||||
версий требует повторной проверки библиотек и bootstrap; одного обновления
|
||||
lock-файла недостаточно.
|
||||
|
||||
Нижняя граница `>=12.4` — ctranslate2 собран с CUDA 12.4. Пакет доступен только
|
||||
для Linux x86_64 (на Windows и macOS не устанавливается по platform marker).
|
||||
### Linux: preload по полному пути
|
||||
|
||||
### ctypes.CDLL preload вместо LD_LIBRARY_PATH
|
||||
`_cuda_bootstrap.py` загружает `libcublas.so.12` через `ctypes.CDLL` с
|
||||
`RTLD_GLOBAL`. Линкер затем разрешает запрос CTranslate2 по soname.
|
||||
Изменение `LD_LIBRARY_PATH` внутри процесса не решает задачу: glibc кеширует
|
||||
пути поиска. Путь optional-пакета определяется через `nvidia.cublas.__path__`,
|
||||
что поддерживает namespace package без `__file__`.
|
||||
|
||||
`_cuda_bootstrap.py` загружает `libcublas.so.12` по полному пути через
|
||||
`ctypes.CDLL(path, mode=RTLD_GLOBAL)` **до** первого `import ctranslate2`.
|
||||
### Windows: регистрация каталога и preload DLL
|
||||
|
||||
Почему не `os.environ["LD_LIBRARY_PATH"]`: на Linux/glibc динамический линкер (`ld.so`)
|
||||
кеширует пути поиска при старте процесса и **не перечитывает** `LD_LIBRARY_PATH`
|
||||
из environ в рамках уже запущенного процесса.
|
||||
Bootstrap регистрирует `nvidia/cublas/bin` через `os.add_dll_directory`, затем
|
||||
загружает по абсолютным путям сначала `cublasLt64_12.dll`, затем
|
||||
`cublas64_12.dll`. Handles библиотек и регистрации каталога хранятся до
|
||||
завершения процесса. Повторный вызов не загружает их заново. Это обеспечивает
|
||||
доступность уже загруженных DLL для динамического загрузчика CTranslate2 и
|
||||
не требует изменения системного PATH.
|
||||
|
||||
Почему `RTLD_GLOBAL`: без этого флага символы cublas не видны другим `.so`,
|
||||
загруженным позже (в т.ч. `libctranslate2.so`).
|
||||
Bootstrap вызывается только при создании CUDA-модели, до локального импорта
|
||||
`WhisperModel`. Если CTranslate2 ранее импортирован для декодирования аудио,
|
||||
preload всё равно выполняется до создания CUDA-модели. CPU-путь bootstrap не
|
||||
вызывает. Отсутствие optional-пакета безопасно: системная cuBLAS всё ещё может
|
||||
обслужить явно выбранную CUDA. Ошибка загрузки найденной Windows DLL сохраняется.
|
||||
|
||||
Динамический линкер кеширует загруженные библиотеки по soname — когда ctranslate2
|
||||
потом вызовет `dlopen("libcublas.so.12")`, линкер вернёт уже загруженный handle.
|
||||
### Ошибки и явный выбор
|
||||
|
||||
### strict_device для явного --device
|
||||
Явный `device` из CLI или TOML передаёт `strict_device=True` при загрузке и
|
||||
транскрипции. CUDA не заменяется молча на CPU. Подсказки различают отсутствие
|
||||
библиотек, проблемы драйвера, несовместимость GPU/runtime и нехватку памяти;
|
||||
отказ по неподдерживаемому типу вычислений учитывает устройство, чтобы
|
||||
одинаковая ошибка CTranslate2 на CPU не получала CUDA-подсказку. Исходное
|
||||
сообщение сохраняется. Неизвестная ошибка не получает произвольный
|
||||
совет установить Toolkit. Это одинаково для одного файла и батча.
|
||||
|
||||
`--device cuda` / `--device cpu` → `strict_device=True` → CUDA-ошибка = raise, без fallback.
|
||||
`--device auto` → `strict_device=False` → текущее поведение с fallback на CPU.
|
||||
Прежний механизм fallback оркестратора при `strict_device=False` сохранён
|
||||
для существующих программных callers. Автоматический CLI-путь теперь ONNX,
|
||||
поэтому он не попадает в CUDA fallback.
|
||||
|
||||
Мотивация: silent fallback для часового файла = 60 минут вместо 5.
|
||||
## Проверка зависимостей Windows
|
||||
|
||||
## Последствия и tradeoffs
|
||||
Статическая проверка 2026-09-21: скачаны wheels из PyPI, проверены SHA-256,
|
||||
для DLL/PYD прочитаны PE imports через `objdump -p` и строки динамической
|
||||
загрузки. Модели распознавания не скачивались.
|
||||
|
||||
**~400 MB на CPU-only Linux x86_64**: nvidia-cublas-cu12 ставится на все Linux x86_64,
|
||||
включая машины без GPU. Bootstrap при этом preload'ит libcublas (overhead ~1 ms),
|
||||
но она не используется, т.к. ctranslate2 не получит запрос на CUDA device.
|
||||
- `ctranslate2-4.8.1-cp313-cp313-win_amd64.whl`, SHA-256
|
||||
`d52499f05a60a791aeadee28d609efa130142f376d1ea76b2b1c593bb01f8827`.
|
||||
`ctranslate2.dll` содержит имена `cublas64_12.dll` и `nvcuda.dll`;
|
||||
ссылок на cuDNN и динамическую cudart в этой DLL не обнаружено.
|
||||
В wheel есть `cudnn64_9.dll`, но её наличие не означает использование
|
||||
cuDNN проверенной рабочей DLL. OpenMP DLL поставляется внутри wheel;
|
||||
импорты MSVCP/VCRUNTIME требуют Visual C++ Runtime.
|
||||
- `nvidia_cublas_cu12-12.9.1.4-py3-none-win_amd64.whl`, SHA-256
|
||||
`1e5fee10662e6e52bd71dec533fbbd4971bb70a5f24f3bc3793e5c2e9dc640bf`.
|
||||
DLL находятся в `nvidia/cublas/bin`. cuBLAS импортирует cuBLAS Lt и
|
||||
KERNEL32; cuBLAS Lt импортирует KERNEL32. В строках cuBLAS Lt также найдена
|
||||
динамическая ссылка на `nvrtc64_120_0.dll`, которой нет в PE imports.
|
||||
Обязательность NVRTC для Whisper этим статическим анализом не установлена;
|
||||
первый реальный Windows-прогон должен проверить и этот путь загрузки.
|
||||
`nvblas64_12.dll` не требуется проверенному пути CTranslate2.
|
||||
|
||||
Альтернатива — `[project.optional-dependencies]` + `uv sync --extra cuda`,
|
||||
но тогда теряется zero-config UX. Для v1 оставляем как обязательную зависимость.
|
||||
Основания: [CTranslate2 4.8.1 на PyPI](https://pypi.org/project/ctranslate2/4.8.1/#files),
|
||||
[cuBLAS 12.9.1.4 на PyPI](https://pypi.org/project/nvidia-cublas-cu12/12.9.1.4/#files),
|
||||
[загрузчик CTranslate2](https://github.com/OpenNMT/CTranslate2/blob/v4.8.1/python/ctranslate2/__init__.py),
|
||||
[метаданные cuBLAS 12.9.2.10](https://pypi.org/pypi/nvidia-cublas-cu12/12.9.2.10/json).
|
||||
|
||||
**Windows GPU**: nvidia-cublas-cu12 недоступен как pip-пакет для Windows.
|
||||
Единственный путь — системный CUDA toolkit (`choco install cuda` / `winget install -e --id Nvidia.CUDA`).
|
||||
CLI выводит эту подсказку при CUDA-ошибке на `sys.platform == "win32"`.
|
||||
Это обоснование состава extra для конкретных wheels, а не подтверждение
|
||||
транскрипции на Windows GPU. Реальный прогон Windows x64 с драйвером и extra,
|
||||
без Toolkit и ручной правки PATH, **не выполнен**. Linux-тесты с подменой
|
||||
Windows API не заменяют такую проверку.
|
||||
|
||||
**Namespace package**: `nvidia.cublas` — namespace package (`__file__` is `None`),
|
||||
для определения директории используется `__path__[0]`, а не `__file__`.
|
||||
## Проверка поставки и поведения
|
||||
|
||||
## Отклонённые альтернативы
|
||||
На Linux выполнены `uv run pytest`: 524 passed, 2 skipped. Пропуски —
|
||||
Windows-only кодировка OEM и интеграционный bootstrap без optional-пакета.
|
||||
До удаления cuBLAS интеграционный тест Linux bootstrap проходил.
|
||||
Обычный `uv sync --locked` удаляет прежнюю обязательную cuBLAS;
|
||||
`uv sync --extra cuda --locked --dry-run` возвращает её в план установки.
|
||||
|
||||
| Альтернатива | Почему отклонена |
|
||||
|---|---|
|
||||
| `nvidia-cudnn-cu12` в зависимостях | ctranslate2 не использует cuDNN — ноль символов, проверено через `nm -D` |
|
||||
| Self-reexec с `LD_LIBRARY_PATH` | ctypes.CDLL решает задачу без перезапуска процесса; self-reexec создаёт проблемы с сигналами, tty, fd |
|
||||
| `os.environ["LD_LIBRARY_PATH"] += ...` | glibc кеширует пути при старте, не перечитывает environ |
|
||||
| `DeviceResolution` dataclass + `resolve_device()` | Один `bool strict_device` решает ту же задачу проще |
|
||||
| ctranslate2 preflight (`get_supported_compute_types`) | Латентность; try/catch при загрузке модели не хуже |
|
||||
| Bootstrap console_scripts entrypoint | Не нужен — достаточно вызова в начале `transcriber.py` |
|
||||
Метаданные собранного wheel проверены для 12 сочетаний платформы, архитектуры
|
||||
и extra. Полное разрешение зависимостей wheel через `uv pip compile` для Linux
|
||||
и Windows, с extra и без, подтверждает отсутствие NVIDIA-пакетов в обычной
|
||||
установке и наличие только cuBLAS 12.9.1.4 при выборе extra.
|
||||
|
||||
Формы `uv tool install` для клона, клона с extra и именованной Git-зависимости
|
||||
с extra проверены в отдельном временном каталоге tools. Для проверки самой
|
||||
команды зависимости были исключены; их разрешение проверено отдельно выше.
|
||||
Git-транспорт проверялся через локальный репозиторий и ветку реализации,
|
||||
а не через ещё не опубликованную ветку на сервере. Версия uv — 0.12.17.
|
||||
|
||||
## Последствия и альтернативы
|
||||
|
||||
- Обычная установка не требует NVIDIA-пакетов. FasterWhisper CPU сохраняется.
|
||||
- Дополнительные библиотеки скачиваются только по выбору пользователя.
|
||||
- Единый extra заменяет требование установить полный Toolkit на Windows;
|
||||
существующая системная установка библиотек остаётся допустимой.
|
||||
- Отклонено сохранение обязательной cuBLAS ради готовности CUDA сразу после
|
||||
установки: это увеличивает CPU-установку и не решает несовместимость GPU.
|
||||
- Отклонено автоматическое включение CUDA по драйверу или extra: установка
|
||||
библиотек не должна менять движок и модель по умолчанию.
|
||||
- Перезапуск процесса с `LD_LIBRARY_PATH` не нужен; явная загрузка библиотек
|
||||
решает проблему без изменения обработки сигналов и файловых дескрипторов.
|
||||
- CUDA extra на Windows ARM64, Linux ARM и macOS в эту задачу не входит.
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
**Статус**: Принято
|
||||
**Дата**: 2026-03-18
|
||||
**Обновлено**: 2026-09-21 — решения 2 и 4 пересмотрены (см. пометки)
|
||||
|
||||
## Контекст
|
||||
|
||||
@@ -17,19 +18,23 @@
|
||||
|
||||
**Почему**: загрузка модели (large-v3) занимает ~10 секунд и ~3 GB RAM/VRAM. Повторный запуск по уже обработанным файлам должен быть дешёвым no-op.
|
||||
|
||||
### 2. TranscribeFileResult: возврат обновлённого состояния модели
|
||||
### 2. Состояние модели между файлами
|
||||
|
||||
При mid-stream CUDA fallback `_transcribe_file()` перезагружает модель на CPU внутри себя. Чтобы следующие файлы в батче не грузили модель повторно, результат включает обновлённые `model` и `actual_device`.
|
||||
**Пересмотрено 2026-09-21.** Исходное решение: `_transcribe_file()` возвращал
|
||||
`TranscribeFileResult` с обновлёнными `model`, `backend`, `model_path`
|
||||
и `actual_device`, а CLI переносил их в следующий файл после mid-stream
|
||||
fallback. Это делало caller владельцем согласованности четырёх полей.
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class TranscribeFileResult:
|
||||
result: TranscribeResult
|
||||
model: WhisperModel # может измениться при fallback
|
||||
actual_device: str # может измениться при fallback
|
||||
```
|
||||
Теперь состояние не покидает module выполнения: `Transcriber` создаётся один
|
||||
раз на запуск после prescan, загружает модель при первом файле и сам держит
|
||||
загруженную модель, adapter и фактическое исполнение. Fallback меняет
|
||||
состояние внутри module, и следующий файл батча идёт уже по нему. Caller
|
||||
получает `TranscribeResult` и `ExecutionInfo` (запрошенное и выбранное
|
||||
исполнение, движок, модель, квантизация, потоки, версии runtime).
|
||||
|
||||
**Альтернатива**: передавать модель по ссылке через mutable контейнер — менее явно и сложнее тестировать.
|
||||
**Альтернатива**: mutable-контейнер с моделью, передаваемый по ссылке, —
|
||||
отклонена тогда же; owner-объект решает ту же задачу явно и тестируется
|
||||
через свой interface.
|
||||
|
||||
### 3. Конфиг: CWD → глобальный, без мержа
|
||||
|
||||
@@ -43,15 +48,18 @@ class TranscribeFileResult:
|
||||
|
||||
**Приоритет значений**: CLI > конфиг > хардкод.
|
||||
|
||||
### 4. `_transcribe_file()` — внутренний helper
|
||||
### 4. Публичный `transcribe()` — путь одного файла
|
||||
|
||||
Публичный API (`transcribe()`) сохранён без изменений. Новая функция `_transcribe_file()` — внутренний helper с префиксом `_`, не часть публичного контракта.
|
||||
|
||||
`transcribe()` стала тонкой обёрткой: `load_model()` + `_transcribe_file()` → `TranscribeResult`.
|
||||
**Пересмотрено 2026-09-21.** Внутренние helpers `load_model()`
|
||||
и `_transcribe_file()` удалены. `transcribe()` сохранён и стал обёрткой над
|
||||
тем же `Transcriber`: `model_name` и `compute_type` без значения берут
|
||||
умолчания выбранного исполнения, `device="auto"` означает ONNX CPU, как в CLI.
|
||||
|
||||
## Последствия
|
||||
|
||||
- Обратная совместимость CLI: `transcribe file.mp4` работает как раньше
|
||||
- Все существующие тесты проходят без изменений сигнатур
|
||||
- Batch mode: модель загружается один раз для всех файлов
|
||||
- Batch mode: модель загружается один раз для всех файлов; CLI не переносит
|
||||
состояние между файлами
|
||||
- Fallback из CLI недостижим: явный device — strict, `auto` — ONNX CPU без
|
||||
переходов. Механизм сохранён для `transcribe(..., strict_device=False)`
|
||||
- `--force` флаг для перезаписи существующих транскриптов в батч-режиме
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Статус**: Принято
|
||||
**Дата**: 2026-03-21
|
||||
**Обновлено**: 2026-08-12
|
||||
**Обновлено**: 2026-09-21
|
||||
|
||||
## Контекст
|
||||
|
||||
@@ -32,8 +32,9 @@ Protocol вместо ABC — бэкенды не наследуются, дос
|
||||
### Ленивые импорты
|
||||
|
||||
Бэкенды импортируются только при выборе — `get_backend(device)` делает import внутри.
|
||||
Импорт faster-whisper запускает CUDA bootstrap (~1ms), импорт openvino-genai загружает ~50MB
|
||||
shared libraries. Ни то, ни другое не должно происходить, если бэкенд не выбран.
|
||||
CUDA bootstrap вызывается только при создании CUDA-модели (см. ADR-001).
|
||||
Импорт openvino-genai загружает ~50MB shared libraries и не должен происходить,
|
||||
если бэкенд не выбран.
|
||||
|
||||
### Device как селектор бэкенда
|
||||
|
||||
@@ -41,23 +42,41 @@ shared libraries. Ни то, ни другое не должно происхо
|
||||
- `cuda`, `cpu` → FasterWhisperBackend
|
||||
- `openvino` → OpenVINOBackend
|
||||
- `onnx` → OnnxAsrBackend
|
||||
- `auto` → CUDA при наличии `nvidia-smi`, иначе ONNX на CPU
|
||||
- `auto` → ONNX на CPU независимо от `nvidia-smi` и установки extra
|
||||
|
||||
### load_model() — единственный владелец pipeline
|
||||
### Transcriber — единственный владелец загрузки и исполнения
|
||||
|
||||
`load_model()` выполняет ensure_model_available + create_model в одном вызове.
|
||||
CLI не вызывает ensure_model_available отдельно — это убирает двойной resolution
|
||||
и гарантирует, что модель скачивается для правильного бэкенда.
|
||||
Обновлено 2026-09-21. `Transcriber(ExecutionRequest)` в `transcriber.py`
|
||||
разрешает `auto` и device-aware умолчания, выполняет ensure_model_available +
|
||||
create_model в одном месте и держит модель, adapter и фактическое исполнение
|
||||
на весь запуск. CLI не вызывает `get_backend` и не хранит handles.
|
||||
|
||||
Внутри module различаются три вещи: поддерживаемая модель, движок
|
||||
распознавания (`faster-whisper`, `openvino`, `onnx-asr`) и способ исполнения.
|
||||
Значение `device` из CLI/TOML сохраняется как вход: `get_backend` выбирает
|
||||
по нему adapter, adapter называет свой движок атрибутом `engine`, а
|
||||
аппаратную часть (CPU, CUDA, Intel GPU, список providers) трактует сам.
|
||||
Новое исполнение — новое значение `device` в `get_backend` и его понимание
|
||||
в adapter'е, не новая иерархия adapters.
|
||||
|
||||
### Cross-backend fallback
|
||||
|
||||
Fallback живёт в `transcriber.py` (оркестратор), не в бэкендах:
|
||||
Fallback живёт внутри `Transcriber`, не в бэкендах и не в CLI:
|
||||
- CUDA ошибка → CPU (FasterWhisper)
|
||||
- OpenVINO ошибка → CPU (FasterWhisper)
|
||||
- `strict_device=True` (явный `--device`) → ошибка без fallback
|
||||
- strict запуск (явный device из CLI или TOML) → ошибка без fallback
|
||||
|
||||
При fallback в батч-режиме обновляются model, backend, model_path и actual_device
|
||||
через TranscribeFileResult — следующий файл использует правильный бэкенд.
|
||||
После перехода module сам держит новое состояние; следующий файл батча
|
||||
использует его без участия caller. `WordTimestampsUnavailableError`
|
||||
и ошибки пользовательских данных переходом не считаются.
|
||||
|
||||
### Диагностика исполнения
|
||||
|
||||
Каждый adapter отдаёт `runtime_info()` — версии runtime и фактическую
|
||||
конфигурацию (для ONNX — доступные providers и те, что заданы сессиям ASR
|
||||
и VAD, квантизация, бюджет потоков). `Transcriber` включает их в
|
||||
`ExecutionInfo`; CLI печатает их в `--verbose`. Наличие provider в wheel не
|
||||
считается доказательством выполнения графа на GPU/NPU.
|
||||
|
||||
### Аудио для OpenVINO
|
||||
|
||||
@@ -76,13 +95,13 @@ OpenVINO модели предквантизированы (int8/fp16), compute_
|
||||
### Зависимости бэкендов по умолчанию
|
||||
|
||||
faster-whisper, onnx-asr/onnxruntime и openvino-genai ставятся вместе. Модели
|
||||
скачиваются только для активного бэкенда. CUDA (`nvidia-cublas-cu12`) остаётся
|
||||
conditional для Linux x86_64. OpenVINO — conditional для x86_64/AMD64, кроме
|
||||
скачиваются только для активного бэкенда. cuBLAS подключается через extra `cuda`
|
||||
для Linux/WSL x86_64 и Windows x64 (см. ADR-001). OpenVINO — conditional для x86_64/AMD64, кроме
|
||||
macOS; ONNX обеспечивает автоматический CPU-путь на остальных платформах.
|
||||
|
||||
## Последствия
|
||||
|
||||
- Обратная совместимость: `transcribe()` сохранён; `load_model()` изменил сигнатуру (возвращает 4-tuple вместо 2-tuple, добавлен `compute_type_explicit`)
|
||||
- Обратная совместимость: `transcribe()` сохранён; `load_model()` и `_transcribe_file()` удалены (2026-09-21)
|
||||
- Новый бэкенд добавляется одним файлом в `backends/` + регистрацией в `__init__.py`
|
||||
- Модели скачиваются по запросу — CUDA пользователь не качает OpenVINO модели, и наоборот
|
||||
- ARM и macOS: OpenVINO не ставится (platform markers), auto использует ONNX
|
||||
|
||||
@@ -102,10 +102,12 @@ Mm-hmm-редукция и иностранные вставки **не обна
|
||||
## Решение
|
||||
|
||||
**Принять onnx-asr как CPU-бэкенд автоматического профиля.
|
||||
GigaAM v3 E2E RNN-T — модель по умолчанию на машинах без CUDA.**
|
||||
GigaAM v3 E2E RNN-T — модель автоматического профиля на всех машинах.**
|
||||
|
||||
Порядок `--device auto`: CUDA при наличии `nvidia-smi`, иначе ONNX. OpenVINO и
|
||||
FasterWhisper CPU остаются доступными через явный CLI-аргумент или конфиг.
|
||||
С 2026-09-21 `--device auto` всегда выбирает ONNX CPU; для ASR и VAD явно
|
||||
передаётся `CPUExecutionProvider`, независимо от доступных providers. CUDA,
|
||||
OpenVINO и FasterWhisper CPU доступны через явный CLI-аргумент или конфиг.
|
||||
Подключение CUDA описано в [ADR-001](001-cuda-bootstrap.md).
|
||||
|
||||
GigaAM понимает только русскую речь, поэтому для остальных языков автоматический
|
||||
профиль не годится — нужен явный Whisper. Несовместимый язык работу не
|
||||
|
||||
@@ -2,6 +2,23 @@
|
||||
|
||||
**Статус**: Принято
|
||||
**Дата**: 2026-08-14
|
||||
**Обновлено**: 2026-08-30
|
||||
|
||||
## Решение
|
||||
|
||||
Диаризацию реализуем как явно включаемый пост-процессинг через `sherpa-onnx`.
|
||||
Говорящий назначается слову с временной привязкой, а не целому сегменту
|
||||
распознавания. Модель по умолчанию остаётся прежней: Pyannote Segmentation 3.0 FP32,
|
||||
WeSpeaker ResNet34 LM, порог кластеризации `0,89` и автоматическое число
|
||||
кластеров. Известное число говорящих можно передать явно.
|
||||
|
||||
Повторный CPU-тест FP32/INT8 × WeSpeaker/TitaNet не меняет это решение.
|
||||
INT8 с WeSpeaker дал только около 2% среднего ускорения без существенного
|
||||
выигрыша памяти и с худшими прокси-метриками. TitaNet оказался примерно на
|
||||
четверть быстрее, но потребовал ещё 147–149 МиБ RSS и не прошёл условие приёмки
|
||||
для общего автоматического порога. TitaNet можно исследовать только для режима
|
||||
с явно известным числом говорящих. Такой эксперимент должен завершиться
|
||||
слуховой проверкой. Код и модели по умолчанию на него не переключаем.
|
||||
|
||||
## Контекст
|
||||
|
||||
@@ -21,12 +38,20 @@
|
||||
## Эксперимент
|
||||
|
||||
Локальная связка `sherpa-onnx` с сегментацией Pyannote 3.0 и эмбеддингами
|
||||
WeSpeaker ResNet34 LM проверена на трёх записях с известным составом. Для
|
||||
автоматического определения числа голосовых кластеров выбран порог 0,89: это
|
||||
единственное проверенное значение, которое на трёх контрольных фрагментах дало
|
||||
3 / 2 / 2 кластера. На полной встрече втроём остался ложный кластер длительностью
|
||||
19,1 секунды; поэтому малые кластеры нельзя молча отбрасывать, а разметку нельзя
|
||||
считать эталоном точных границ и перекрывающейся речи.
|
||||
WeSpeaker ResNet34 LM проверена на трёх записях с известным составом. В исходной
|
||||
калибровке для автоматического определения числа голосовых кластеров выбран
|
||||
порог 0,89: среди проверенных тогда значений только он дал на трёх контрольных
|
||||
фрагментах `3 / 2 / 2` кластера. На полной встрече втроём остался ложный кластер
|
||||
длительностью 19,1 секунды; поэтому малые кластеры нельзя молча отбрасывать, а
|
||||
разметку нельзя считать эталоном точных границ и перекрывающейся речи.
|
||||
|
||||
В повторном CPU-переборе на тех же пятиминутных фрагментах FP32 + WeSpeaker
|
||||
прошёл условие приёмки `3 / 2 / 2` на плато `0,85–0,89`, а INT8 + WeSpeaker —
|
||||
на `0,85–0,90`.
|
||||
Это не отменяет порог 0,89: новый прогон не имел повторов и новой слуховой
|
||||
проверки. Обе связки TitaNet провалили условие приёмки во всех проверенных
|
||||
точках; число содержательных кластеров не использовалось как оправдание лишних
|
||||
голосовых кластеров.
|
||||
|
||||
Двуязычная CAMPPlus zh/en оказалась примерно на 30% быстрее и при известном
|
||||
числе участников улучшила прокси-метрику на двух записях, но для неё не нашлось
|
||||
@@ -44,6 +69,8 @@ WeSpeaker ResNet34 LM проверена на трёх записях с изв
|
||||
[калибровке](../benchmarks/2026-08-14-diarization-calibration.md),
|
||||
[смешении говорящих](../benchmarks/2026-08-14-asr-segment-speaker-mixing.md) и
|
||||
[производительности на Intel](../benchmarks/2026-08-14-diarization-intel-i7.md).
|
||||
Сравнение облегчённых моделей и границы его применимости описаны в
|
||||
[отдельном отчёте](../benchmarks/2026-08-30-lightweight-diarization-cpu.md).
|
||||
|
||||
### Сглаживание неизвестного говорящего
|
||||
|
||||
@@ -78,12 +105,7 @@ speaker Markdown хранит только начало реплики. Тем
|
||||
невозможна, потому что в нём уже потеряны доли секунды, конец слова и причина
|
||||
неопределённости.
|
||||
|
||||
## Решение
|
||||
|
||||
Диаризацию реализуем как явно включаемый пост-процессинг через `sherpa-onnx`.
|
||||
Первая версия использует Pyannote segmentation 3.0, WeSpeaker ResNet34 LM,
|
||||
порог кластеризации 0,89 и автоматическое число кластеров; известное число
|
||||
участников можно передать явно.
|
||||
## Сведение слов и разметки говорящих
|
||||
|
||||
Говорящий назначается [слову с временной
|
||||
привязкой](../../CONTEXT.md#language), а не сегменту распознавания. Каждый
|
||||
@@ -119,6 +141,9 @@ ASR-бэкенд приводит свой результат к общему н
|
||||
- Последовательный режим задаёт корректный baseline. Параллельный запуск и
|
||||
автоматическое включение на мощных устройствах требуют отдельных измерений
|
||||
после стабилизации.
|
||||
- Pyannote INT8 и TitaNet не становятся конфигурациями по умолчанию только на
|
||||
основании CPU-теста. Для TitaNet следующий допустимый эксперимент —
|
||||
режим с явно известным числом говорящих, повторами и слуховой проверкой.
|
||||
|
||||
## Отклонённые альтернативы
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
- Основной remote: `origin`
|
||||
- Gitea: `https://git.dementev.space`
|
||||
- CLI: `tea`
|
||||
- Remote `github` является зеркалом и не используется для управления задачами
|
||||
- Внешние pull request не входят в очередь triage
|
||||
|
||||
## Доступ
|
||||
|
||||
+24
-11
@@ -13,7 +13,12 @@
|
||||
**Промежуточный статус 2026-08-11:** короткая матрица на трёх записях завершена
|
||||
и описана в [benchmark GigaAM и Whisper](benchmarks/2026-08-11-gigaam-model-comparison.md).
|
||||
Multilingual Large добавлена как явный качественный профиль; решение о default
|
||||
по-прежнему ждёт целевого Intel Core i5 и длинных негативных примеров.
|
||||
по-прежнему ждет длинных негативных примеров и проверки производительности на
|
||||
нескольких доступных классах CPU.
|
||||
|
||||
Под CPU-дефолтом в этом разделе понимается программный профиль: backend, модель
|
||||
и `compute_type`, выбранные без явных аргументов пользователя. Целевого
|
||||
процессора и обязательного эталонного SKU у проекта нет.
|
||||
|
||||
**Проблема:** текущий CPU-путь через OpenVINO Whisper medium нестабилен на
|
||||
длинных записях с тихими участками: модель генерирует правдоподобные повторы и
|
||||
@@ -68,10 +73,16 @@ Multilingual Large добавлена как явный качественный
|
||||
|
||||
**Матрица производительности:**
|
||||
|
||||
- обязательный прогон на реальном Intel Core i5 11-го поколения; точный SKU,
|
||||
число ядер, объём RAM, power mode и число потоков записать вместе с результатом;
|
||||
- Ryzen 7 8845H разработчика и ограничение числа потоков использовать только для
|
||||
предварительного smoke-теста, не как приёмку производительности i5;
|
||||
- использовать минимум два доступных x86-ориентира: консервативный baseline на
|
||||
Intel Core i7-6820HQ и современный baseline на AMD Ryzen 7 8845H;
|
||||
- для каждого прогона записывать точный SKU, физические ядра и логические
|
||||
потоки, CPUID/ISA, объем RAM, power mode и фактическое число потоков;
|
||||
- Ryzen использовать как основную машину для итераций и как современный
|
||||
контролируемый baseline. Результат относится только к зафиксированной
|
||||
конфигурации;
|
||||
- при доступности отдельно собирать результаты на других x86-ноутбуках и Apple
|
||||
Silicon как данные о переносимости; в контролируемую матрицу входят два
|
||||
предыдущих x86 baseline;
|
||||
- измерять отдельно cold start/загрузку модели и warm transcription, медиану
|
||||
трёх прогонов, RTFx, peak RSS и размер скачиваемой модели;
|
||||
- короткий 15-минутный фрагмент нужен для итераций, полные записи 22–81 мин —
|
||||
@@ -92,8 +103,9 @@ Multilingual Large добавлена как явный качественный
|
||||
|
||||
**Если подтвердится бенчмарком:**
|
||||
|
||||
- Если E2E RNN-T сохраняет качество и приемлемую скорость на i5 — сделать его
|
||||
русским CPU-дефолтом, OpenVINO оставить явной опцией.
|
||||
- Если E2E RNN-T сохраняет качество и приемлемую скорость на выбранной
|
||||
CPU-матрице - сделать его русским CPU-дефолтом, OpenVINO оставить явной
|
||||
опцией.
|
||||
- Если лучший вариант зависит от языка — выбрать language-aware default:
|
||||
GigaAM v3 для русского, multilingual-модель для смешанной речи.
|
||||
- Если модели GigaAM проигрывают по пунктуации/смыслу — сохранить текущий
|
||||
@@ -135,10 +147,11 @@ Multilingual Large добавлена как явный качественный
|
||||
- Переключить русский CPU-дефолт на победителя сравнительного GigaAM-бенчмарка.
|
||||
- Предупреждать пользователя при `--device openvino-cpu` для файлов >30 мин.
|
||||
|
||||
**Приоритет:** высокий — проблема затрагивает текущий OpenVINO CPU-путь, а
|
||||
целевая аудитория включает ноутбуки с Intel Core i5 11-го поколения. GigaAM v3
|
||||
остаётся рабочей явной альтернативой для русского, но выбор безопасного
|
||||
auto/default требует сравнительного бенчмарка.
|
||||
**Приоритет:** высокий - проблема затрагивает текущий OpenVINO CPU-путь, а
|
||||
пользователи работают на ноутбуках разных поколений и производителей.
|
||||
Среди них встречаются и более скромные машины уровня Intel Core i5 11-го
|
||||
поколения. GigaAM v3 остается рабочей явной альтернативой для русского, но
|
||||
выбор безопасного auto/default требует сравнительного бенчмарка.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# Бенчмарк: качество и скорость транскрипции
|
||||
|
||||
**Дата**: 2026-03-23
|
||||
|
||||
**Статус:** архив. Перенесено из ветки `feature/amd-acceleration`; конфигурации бэкендов и умолчания с тех пор изменились (ONNX CPU по умолчанию, см. ADR-003).
|
||||
|
||||
## Цель
|
||||
|
||||
Сравнить качество распознавания нашего приложения (local-transcriber) с облачным
|
||||
сервисом Whisper.ai, подобрать оптимальную конфигурацию бэкенд/модель/compute_type.
|
||||
|
||||
## Тестовый стенд
|
||||
|
||||
- **CPU**: AMD Ryzen 7 8845H w/ Radeon 780M (3.80 GHz), 8 ядер
|
||||
- **GPU**: нет NVIDIA (нет CUDA), есть встроенный Radeon 780M iGPU + Ryzen AI NPU (XDNA)
|
||||
- **ОС**: Windows 11 Enterprise
|
||||
- **Запись**: совещание 3 участников, 14:41, русский язык, микрофон ноутбука
|
||||
|
||||
## Конфигурации
|
||||
|
||||
| # | Модель | Бэкенд | compute_type | Устройство |
|
||||
|---|--------|--------|--------------|------------|
|
||||
| 1 | medium | OpenVINO | fp16 | CPU |
|
||||
| 2 | large-v3 | OpenVINO | fp16 | CPU |
|
||||
| 3 | medium | CTranslate2 (faster-whisper) | float32 | CPU |
|
||||
| 4 | medium | CTranslate2 (faster-whisper) | int8_float32 | CPU |
|
||||
| 5 | — | Whisper.ai (облако) | — | — |
|
||||
|
||||
## Результаты: скорость
|
||||
|
||||
| # | Конфигурация | Время, с | Коэфф. к реалтайму (14:41 = 881с) |
|
||||
|---|---|---|---|
|
||||
| 1 | medium, OpenVINO fp16 | **~240** | 0.27x |
|
||||
| 2 | large-v3, OpenVINO fp16 | ~503 | 0.57x |
|
||||
| 4 | medium, CT2 int8_float32 | ~599 | 0.68x |
|
||||
| 3 | medium, CT2 float32 | ~881 | 1.0x |
|
||||
|
||||
## Результаты: качество
|
||||
|
||||
Для оценки выбраны маркерные фразы — технические термины, сложная разговорная речь,
|
||||
имена собственные. Каждый маркер проверен вручную по контексту записи.
|
||||
|
||||
### Техтермины (английские в русской речи)
|
||||
|
||||
| Термин | #1 OV fp16 | #2 large OV | #3 CT2 f32 | #4 CT2 int8f32 | #5 Whisper.ai |
|
||||
|--------|-----------|-------------|------------|----------------|---------------|
|
||||
| DWH | ✅ DWH | ❌ ДВХ | ⚠️ ДВХ | ✅ DWH | ⚠️ dvh |
|
||||
| Teradata | ✅ TeraData | ⚠️ тирадат | ⚠️ Тирадату | ✅ Teradata | ⚠️ тирадата |
|
||||
| NiFi | ✅ NiFi | ❌ "на файл" | ⚠️ Найфай | ✅ NiFi | ⚠️ фай |
|
||||
| FineBI | ✅ fineBI | ❌ "фанбой" | ✅ FNBI | ✅ finebi/FNBI | ⚠️ FineBI/NBI |
|
||||
| Alation | ❌ "лэйшене" | ❌ "валаши" | ✅ Allation | ✅ Allation | ✅ Allation |
|
||||
| ETL | ❌ ETA | ❌ "и ты или" | ✅ ETL | ✅ ETL | ✅ ETL |
|
||||
| DBC Tables V | ✅ | ❌ "деби си" | ✅ | ✅ | ✅ |
|
||||
| OpenLineage | ⚠️ OpenLinage | ⚠️ "open lineage и о" | ✅ OpenLinage.io | ✅ OpenLinage.io | ✅ Open Lineage.io |
|
||||
|
||||
### Русская разговорная речь
|
||||
|
||||
| Фраза (правильно) | #1 OV fp16 | #2 large OV | #3 CT2 f32 | #4 CT2 int8f32 | #5 Whisper.ai |
|
||||
|--------------------|-----------|-------------|------------|----------------|---------------|
|
||||
| "на другую мониторочку" | ❌ "другом не торчка" | ❌ "другому и" | ✅ "мониторчику" | ⚠️ "мою точку" | ✅ "мониторочку" |
|
||||
| "верхнеуровневое" | ⚠️ "верхверх верхне..." | ✅ | ✅ | ✅ | ✅ |
|
||||
| "датарентген" | ❌ "госпиталин ген" | ❌ "господа тренды" | ✅ "даторентген" | ✅ "даторентген" | ✅ "датарентген" |
|
||||
| "девовские схемы" | ✅ | ❌ "девушки схемы" | ✅ | ✅ | ✅ |
|
||||
| "с накиданными правами" | ✅ | ❌ "накиданных бровами" | ✅ | ✅ | ✅ |
|
||||
| "Вьюхи" | ❌ "в юхе" | — | ✅ "Вьюхи" | ⚠️ "Yuhi" | ❌ "в юхе" |
|
||||
|
||||
### Структура и пунктуация
|
||||
|
||||
| Аспект | #1 OV fp16 | #2 large OV | #3 CT2 f32 | #4 CT2 int8f32 | #5 Whisper.ai |
|
||||
|--------|-----------|-------------|------------|----------------|---------------|
|
||||
| Пунктуация | слабая | слабая | хорошая | хорошая | средняя |
|
||||
| Длина сегментов | ~1 мин | ~1 мин | ~1 мин | ~1 мин | 2–30 сек |
|
||||
| Диаризация | нет | нет | нет | нет | есть (условная) |
|
||||
|
||||
## Ключевые выводы
|
||||
|
||||
### 1. OpenVINO fp16 ухудшает качество medium
|
||||
|
||||
OpenVINO даёт **3.7x ускорение**, но fp16-квантизация на CPU вносит заметные ошибки
|
||||
в распознавание нечёткой русской речи ("госпиталин ген", "ETA"). При этом английские
|
||||
техтермины (DWH, NiFi, FineBI) распознаются хорошо — видимо, они более «чёткие»
|
||||
акустически.
|
||||
|
||||
### 2. large-v3 через OpenVINO — хуже medium
|
||||
|
||||
Удивительный результат: large-v3 с OpenVINO fp16 деградировал по всем параметрам.
|
||||
FineBI → "фанбой", NiFi → "на файл", девовские → "девушки". Гипотеза: fp16 на CPU
|
||||
бьёт сильнее по большим моделям из-за накопления ошибок округления.
|
||||
|
||||
### 3. CTranslate2 float32 — лучшее качество
|
||||
|
||||
Сравнимо с облачным Whisper.ai, а на некоторых маркерах лучше (Вьюхи, DWH).
|
||||
Но **3.7x медленнее** OpenVINO — неприемлемо для длинных записей.
|
||||
|
||||
### 4. int8_float32 — оптимальный баланс
|
||||
|
||||
Mixed precision (int8 матрицы, float32 аккумулятор) даёт:
|
||||
- **1.5x ускорение** vs float32 (599с vs 881с)
|
||||
- Качество практически идентичное float32
|
||||
- Техтермины DWH/Teradata/NiFi даже лучше, чем у float32
|
||||
|
||||
### 5. Whisper.ai — диаризация как преимущество
|
||||
|
||||
Единственное значимое преимущество облачного сервиса — диаризация спикеров.
|
||||
По качеству распознавания наше приложение с CT2 int8_float32 на уровне или лучше.
|
||||
|
||||
## Рекомендации
|
||||
|
||||
### Краткосрочно
|
||||
|
||||
- **Дефолт для CPU без CUDA**: рассмотреть `int8_float32` вместо `float32` — 1.5x быстрее
|
||||
при сопоставимом качестве.
|
||||
- **OpenVINO fp16 оставить** как быстрый вариант — для случаев, когда скорость важнее
|
||||
точности (превью, черновые транскрипты).
|
||||
|
||||
### Среднесрочно
|
||||
|
||||
- **DirectML бэкенд** для AMD Radeon iGPU — через onnxruntime-directml можно задействовать
|
||||
Radeon 780M. Float16 на GPU (в отличие от CPU-квантизации) не должен терять качество.
|
||||
Потенциально 3–5x ускорение при сохранении качества float32.
|
||||
|
||||
### Долгосрочно
|
||||
|
||||
- **Ryzen AI NPU (XDNA)** — AMD развивает поддержку, но tooling пока сырой.
|
||||
- **Диаризация** — добавить идентификацию спикеров (pyannote-audio или аналоги).
|
||||
@@ -0,0 +1,223 @@
|
||||
# Облегчённые модели диаризации на старом Intel Core i7
|
||||
|
||||
**Дата:** 2026-08-30
|
||||
|
||||
**Статус:** сравнительный CPU-тест. Конфигурация по умолчанию не менялась.
|
||||
|
||||
## Решение
|
||||
|
||||
Сохранить модель по умолчанию: Pyannote Segmentation 3.0 FP32, WeSpeaker
|
||||
ResNet34 LM, общий порог автоматической кластеризации `0,89`. Новый прогон
|
||||
подтвердил для этой связки точное число голосовых кластеров `3 / 2 / 2` на всех
|
||||
трёх контрольных фрагментах.
|
||||
|
||||
Pyannote INT8 с WeSpeaker не заменяет FP32. Среднее ускорение составило только
|
||||
2,1%, средний пиковый RSS по ячейкам снизился примерно на 4 МиБ, а средняя
|
||||
чистота сопоставленных говорящих и число слов без говорящего стали хуже.
|
||||
|
||||
TitaNet small в среднем быстрее WeSpeaker на 24,3%, но требует примерно на
|
||||
147–149 МиБ больше RSS и не находит правильное число кластеров ни в одной
|
||||
проверенной точке общего автоматического порога. Его можно рассматривать только
|
||||
как кандидата для режима с явно известным числом говорящих. Следующий эксперимент
|
||||
должен завершиться отдельной слуховой проверкой. По этому тесту менять код,
|
||||
модели или пороги нельзя.
|
||||
|
||||
## Границы вывода
|
||||
|
||||
Проверялись только четыре CPU-сочетания Pyannote FP32/INT8 ×
|
||||
WeSpeaker/TitaNet на трёх пятиминутных русскоязычных рабочих созвонах из
|
||||
[исходной калибровки](2026-08-14-diarization-calibration.md). Число говорящих в
|
||||
них известно как `3 / 2 / 2`. Это сравнительный прогон моделей диаризации, а не
|
||||
новая приёмка готовых транскриптов.
|
||||
|
||||
В частности:
|
||||
|
||||
- новой слуховой проверки не было;
|
||||
- полные записи не прогонялись;
|
||||
- у каждой ячейки один запуск, без повторов и случайного перемешивания порядка;
|
||||
- влияние порядка, прогрева и температуры отдельно не измерено;
|
||||
- ASR выполнен один раз на запись и не входит во время ячеек диаризации;
|
||||
- параллельные ASR и диаризация, GPU, NPU и другие CPU не проверялись.
|
||||
|
||||
Происхождение, лицензии и закреплённые SHA-256 моделей описаны в
|
||||
[исследовательской записке](../research/2026-08-29-lightweight-diarization-models.md),
|
||||
а смысл `mapped speaker purity` и ограничения опорной разметки — в
|
||||
[исходной калибровке](2026-08-14-diarization-calibration.md). Здесь эти сведения
|
||||
не дублируются.
|
||||
|
||||
## Методика
|
||||
|
||||
Каждая измеряемая ячейка запускалась в новом дочернем процессе. До измеряемого
|
||||
интервала загружались WAV, модели и сэмплы; полное время, CPU и пиковый RSS
|
||||
считались вокруг одного вызова `diarizer.process(samples)`. Отладочный журнал
|
||||
`sherpa-onnx` 1.13.5 дал время сегментации, извлечения эмбеддингов, кластеризации
|
||||
и всего вызова.
|
||||
Декодирование, загрузка моделей, сериализация и ASR во время не входят.
|
||||
|
||||
Автоматический перебор прямоугольный: каждый порог сочетания проверен на всех
|
||||
трёх записях с `num_clusters=-1`. Для сочетания выбирался один общий порог.
|
||||
Условие приёмки считалось выполненным только при полном совпадении общего числа
|
||||
кластеров с
|
||||
`3 / 2 / 2`; число содержательных кластеров оставалось диагностикой и не могло
|
||||
оправдать лишний кластер. После выбора порога каждое сочетание ещё раз прошло
|
||||
три записи с известным числом кластеров.
|
||||
|
||||
Итого сохранено 108 успешных ячеек без ошибок:
|
||||
|
||||
- 96 с автоматическим числом кластеров: по 18 для каждого сочетания с
|
||||
WeSpeaker и по 30 для каждого сочетания с TitaNet;
|
||||
- 12 с известным числом кластеров: по три для каждого из четырёх сочетаний.
|
||||
|
||||
Отдельно сохранены четыре результата выбора общего порога. Они описывают
|
||||
агрегацию автоматических ячеек и в число 108 не входят.
|
||||
|
||||
Во всех 108 ячейках совпала последовательность 1853 распознанных слов после
|
||||
удаления Unicode-пробелов. Инвариантность текста не нарушена.
|
||||
|
||||
## Воспроизведение
|
||||
|
||||
Стенд [`diarization_calibration.py`](../../scripts/benchmarks/diarization_calibration.py)
|
||||
закрепляет корпус и матрицу задачи #25. Файл manifest, ASR-sidecar, интервалы и
|
||||
полный результат содержат приватные данные и хранятся отдельно от Git.
|
||||
Относительные пути в manifest считаются от каталога самого файла, поэтому
|
||||
приватный набор можно перенести на другую машину целиком. Готовый файл результата
|
||||
при этом не переносится: продолжение допустимо только на той же платформе, CPU и
|
||||
том же числе логических ядер.
|
||||
|
||||
Перед дорогим запуском нужно проверить набор без декодирования и моделей:
|
||||
|
||||
```bash
|
||||
uv run python scripts/benchmarks/diarization_calibration.py \
|
||||
--manifest /path/to/manifest.json \
|
||||
--validate-only
|
||||
```
|
||||
|
||||
Полный запуск и его продолжение используют одну команду и один файл результата:
|
||||
|
||||
```bash
|
||||
uv run python scripts/benchmarks/diarization_calibration.py \
|
||||
--manifest /path/to/manifest.json \
|
||||
--output /path/to/results.json \
|
||||
--work-dir /path/to/work
|
||||
```
|
||||
|
||||
Совместимый файл продолжится с первой незавершенной ячейки. Код возврата 0
|
||||
означает, что завершены все автоматические ячейки и ячейки с известным числом
|
||||
говорящих. Число кластеров и инвариантность текста нужно сверить с условиями
|
||||
конкретного исследования. Полный контракт аргументов и режим подготовки
|
||||
ASR-sidecar описаны в `--help`. Результат включает исходный manifest,
|
||||
приватные пути и интервалы. Поэтому его нельзя добавлять в Git или передавать
|
||||
внешнему ревьюеру.
|
||||
|
||||
## Оборудование и условия
|
||||
|
||||
- Intel Core i7-6820HQ, 4 ядра / 8 логических процессоров;
|
||||
- Linux 7.0.0-30-generic x86_64;
|
||||
- питание от сети, системный профиль `balanced`;
|
||||
- Python 3.13.13, `sherpa-onnx` 1.13.5, `onnxruntime` 1.28.0,
|
||||
NumPy 2.4.3;
|
||||
- CPU provider и 8 потоков для segmentation и embedding;
|
||||
- mono PCM16, 16 кГц, длительность каждого фрагмента 300 секунд.
|
||||
|
||||
Это тот же ноутбук, что в [предыдущей Intel-приёмке](2026-08-14-diarization-intel-i7.md),
|
||||
но условия нельзя считать побайтовым повтором: там отдельно зафиксированы
|
||||
governor `powersave` и урезанная доступная мощность, а версия ядра была
|
||||
7.0.0-29. Во время нового перебора ночная пауза произошла до сохранения текущей
|
||||
ячейки. Продолжение началось после возврата профиля `balanced`; ячеек,
|
||||
измеренных в энергосберегающем профиле, в итоговом результате нет.
|
||||
|
||||
## Производительность полного автоматического перебора
|
||||
|
||||
Числа ниже — средние по всем автоматическим ячейкам сочетания. Это не повторы
|
||||
одной ячейки. «Средний пиковый RSS» — среднее пикового RSS каждой ячейки;
|
||||
соседний столбец показывает наибольший такой максимум во всём переборе.
|
||||
|
||||
| Сегментация + эмбеддинги | Ячеек | Время, с | RTF | CPU, ядер | Средний пиковый RSS, МиБ | Максимальный пиковый RSS, МиБ | Сегментация, с | Эмбеддинги, с |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| FP32 + WeSpeaker | 18 | 72,486 | 0,24162 | 6,84 | 254,55 | 265,61 | 15,295 | 56,837 |
|
||||
| INT8 + WeSpeaker | 18 | 70,969 | 0,23656 | 6,94 | 250,40 | 263,00 | 17,094 | 53,524 |
|
||||
| FP32 + TitaNet | 30 | 52,797 | 0,17599 | 6,80 | 401,80 | 428,11 | 14,724 | 37,723 |
|
||||
| INT8 + TitaNet | 30 | 55,792 | 0,18597 | 6,70 | 399,10 | 419,71 | 17,125 | 38,278 |
|
||||
|
||||
INT8 ускорил сочетание с WeSpeaker на 2,1%, но сегментация стала медленнее, а
|
||||
выигрыш возник на извлечении эмбеддингов. Такой результат не даёт практического
|
||||
основания заменять FP32. У TitaNet FP32 оказался быстрее INT8. Относительно
|
||||
соответствующей связки WeSpeaker ускорение TitaNet составило 27,2% для FP32 и
|
||||
21,4% для INT8.
|
||||
Среднее для двух вариантов точности — 24,3%.
|
||||
|
||||
Опубликованное ожидание ускорения от INT8 локально не подтвердилось. Вместо
|
||||
18,9% с 3D-Speaker получено 2,1% с WeSpeaker, а с TitaNet INT8 оказался на 5,7%
|
||||
медленнее FP32. Условия исходного примера и границы такого сравнения разобраны
|
||||
в [исследовательской записке](../research/2026-08-29-lightweight-diarization-models.md).
|
||||
|
||||
За скорость TitaNet платит памятью: средний пиковый RSS выше WeSpeaker на 147,25
|
||||
МиБ с FP32 и на 148,70 МиБ с INT8. Поэтому на слабом CPU его нельзя считать
|
||||
безусловно облегчённым вариантом.
|
||||
|
||||
## Автоматический порог и диагностика качества
|
||||
|
||||
`Mapped speaker purity` усреднена только по T2 BDMA и Yantar, для которых есть
|
||||
грубая опорная разметка. Это сравнительная прокси-метрика, не DER и не точность
|
||||
по выверенной разметке.
|
||||
|
||||
| Сочетание | Выбранный порог | Приёмка `3 / 2 / 2` | Кластеры | Средняя purity | Слов без говорящего, из 1853 | Максимальный остаток сверх ожидаемых |
|
||||
|---|---:|:---:|---|---:|---:|---:|
|
||||
| FP32 + WeSpeaker | 0,85 | да | 3 / 2 / 2 | 0,82950 | 71 | 0% |
|
||||
| INT8 + WeSpeaker | 0,85 | да | 3 / 2 / 2 | 0,81028 | 104 | 0% |
|
||||
| FP32 + TitaNet | 1,10 | **нет** | 5 / 3 / 3 | 0,82287 | 76 | 3,89% |
|
||||
| INT8 + TitaNet | 1,10 | **нет** | 3 / 2 / 3 | 0,79315 | 109 | 12,12% |
|
||||
|
||||
У FP32 + WeSpeaker условие приёмки проходит на плато `0,85–0,89`, у INT8 +
|
||||
WeSpeaker — на `0,85–0,90`. Автоматический выбор дал `0,85` по общей
|
||||
лексикографической оценке, но это не аргумент менять рабочий порог `0,89`:
|
||||
новый перебор не содержит повторов или слуховой проверки, тогда как действующее
|
||||
решение опирается также на предыдущую калибровку полных записей и слуховую
|
||||
проверку плотного диалога.
|
||||
|
||||
TitaNet не прошёл условие приёмки ни в одной точке проверенной сетки `0,50–1,10`.
|
||||
Порог `1,10` — лишь наименее плохая точка перебора; допустимой автоматической
|
||||
конфигурацией она не является. Содержательные `3 / 2 / 3` кластера у FP32 TitaNet не отменяют
|
||||
лишние голосовые кластеры: диагностический фильтр не заменяет условие приёмки.
|
||||
|
||||
«Максимальный остаток сверх ожидаемых» — наибольшая по трём записям доля слов,
|
||||
которые попали в кластеры сверх известного числа говорящих. Метрика описывает
|
||||
объём лишних кластеров, а не их количество: один крупный лишний кластер может
|
||||
дать больший процент, чем несколько малых.
|
||||
|
||||
## Режим с известным числом говорящих
|
||||
|
||||
Ячейки с известным числом говорящих использовали тот же выбранный порог и
|
||||
меняли только `num_clusters` на известные `3 / 2 / 2`. Все четыре сочетания
|
||||
вернули точное число кластеров на всех трёх записях.
|
||||
|
||||
| Сочетание | Точное число кластеров | Точное число содержательных кластеров | Средняя purity |
|
||||
|---|---:|---:|---:|
|
||||
| FP32 + WeSpeaker | 3 / 3 записей | 3 / 3 записей | 0,82950 |
|
||||
| INT8 + WeSpeaker | 3 / 3 записей | 3 / 3 записей | 0,81028 |
|
||||
| FP32 + TitaNet | 3 / 3 записей | 2 / 3 записей | 0,83057 |
|
||||
| INT8 + TitaNet | 3 / 3 записей | 3 / 3 записей | 0,79721 |
|
||||
|
||||
Близкая средняя purity FP32 TitaNet и FP32 WeSpeaker не доказывает равноценное
|
||||
качество. У FP32 TitaNet на Data Test один из трёх принудительных кластеров не
|
||||
достиг диагностического порога содержательности, а новой слуховой проверки не
|
||||
было. Поэтому TitaNet остаётся только направлением будущего эксперимента с
|
||||
обязательным известным числом говорящих.
|
||||
|
||||
## Ограничения и следующий шаг
|
||||
|
||||
- Три записи относятся к одному типу рабочих созвонов; результат не переносится
|
||||
автоматически на другие языки, микрофоны и акустические условия.
|
||||
- Две опорные разметки имеют секундную точность и не позволяют считать DER.
|
||||
- Средние значения полного времени и RSS объединяют разные записи и пороги.
|
||||
Без повторов они не оценивают дисперсию и не отделяют порядок, прогрев или
|
||||
влияние температуры.
|
||||
- Новый прогон не заменяет прежнюю слуховую проверку и не даёт слуховых данных
|
||||
для TitaNet или INT8.
|
||||
- Результат относится к последовательной CPU-диаризации на одном Core
|
||||
i7-6820HQ и не предсказывает конкретный Core i5 11-го поколения.
|
||||
|
||||
Если возвращаться к TitaNet, следующий эксперимент должен заранее требовать
|
||||
известное число говорящих, включать повторные прогоны с контролем порядка и
|
||||
температуры и завершаться слуховой проверкой всех трёх записей. До этого
|
||||
конфигурацию и модель по умолчанию не менять.
|
||||
@@ -0,0 +1,292 @@
|
||||
# CPU-диаризация: Intel-этап эксперимента
|
||||
|
||||
**Дата:** 2026-08-31
|
||||
**Статус:** `handoff`; в Ryzen-этап передается один кандидат. Production-профиль не менялся.
|
||||
|
||||
## Итог
|
||||
|
||||
На Intel Core i7-6820HQ пройдены этапы 0-4 эксперимента из
|
||||
[спецификации](../specs/2026-08-30-cpu-diarization-experiment.md). Для Ryzen
|
||||
передается комбинация C2:
|
||||
|
||||
- `window_shift_ratio=0.1`;
|
||||
- Pyannote Segmentation 3.0 FP32;
|
||||
- WeSpeaker ResNet34 LM FP32;
|
||||
- текущий `FastClustering` с порогом `0.89`;
|
||||
- два outer worker, одна общая ONNX Runtime session, общий пул из четырех
|
||||
intra-op потоков, `inter_op_threads=1`.
|
||||
|
||||
C2 сохранила результат baseline по всем обязательным и диагностическим
|
||||
показателям: `3 / 2 / 2` кластера, та же последовательность слов, те же числа
|
||||
слов без говорящего `51 / 13 / 9` и та же `mapped speaker purity`. Один прогон
|
||||
трех фрагментов занял 140,632 с против 146,978 с у соседнего baseline:
|
||||
ускорение 4,32%. Пиковый RSS вырос с 376,4 до 406,0 МиБ. Порог продолжения на
|
||||
Intel не достигнут. Разница неотличима от наблюдаемого разброса
|
||||
одиночных запусков: контрольные baseline в разных сериях лежали в диапазоне
|
||||
146,978-157,892 с, то есть шире, чем разница между W0 и C2.
|
||||
Спецификация при этом разрешает передать работоспособный механизм shared-session
|
||||
как аппаратного кандидата для отдельного A/B на Ryzen. Результат 4,32% остается
|
||||
диагностическим наблюдением; оценивать по нему ожидаемое ускорение нельзя.
|
||||
|
||||
CAMPPlus + NME оказался самым быстрым вариантом: C1 заняла 98,347 с и
|
||||
сохранила `3 / 2 / 2`. Но на одной записи число слов без говорящего
|
||||
выросло с 51 до 53. Для такого изменения спецификация требует слуховой проверки
|
||||
отмеченных мест до сборки комбинации. Материал для нее подготовлен, но в этом
|
||||
окружении прослушивание недоступно. Поэтому C1 измерена и описана, но в handoff
|
||||
не включена.
|
||||
|
||||
## Зачем понадобился sherpa-onnx 1.13.6
|
||||
|
||||
Версия стенда поднята с `1.13.5` до `1.13.6`. В ней публичная Python-конфигурация
|
||||
сегментации получила `window_shift_ratio`, необходимый для этапа W0-W4. Свежий
|
||||
контрольный прогон обеих версий дал одинаковые `3 / 2 / 2` кластера, числа слов
|
||||
без говорящего `51 / 13 / 9` и purity `0,76655 / 0,78742`. Каноническая сверка
|
||||
интервалов и назначения говорящих словам вынесена в отдельную проверку по сырым
|
||||
результатам капсулы; расхождений, требующих прослушивания, не обнаружено. Время
|
||||
одного прогона составило 190,104 с для `1.13.5` и 157,892 с для `1.13.6`; это
|
||||
диагностическое сравнение без повторов, а не самостоятельное доказательство
|
||||
ускорения версии.
|
||||
|
||||
Экспериментальные `Prepare/Finalize` и общий worker API в `1.13.6` отсутствуют.
|
||||
Их нет и в проверенном актуальном upstream `master` sherpa-onnx от 2026-08-31.
|
||||
Для эксперимента собран локальный патч и wheel:
|
||||
|
||||
| Артефакт | SHA-256 |
|
||||
|---|---|
|
||||
| Патч sherpa-onnx 1.13.6 | `5f12df858cd525c8a0cfe8ff95b647c3b0e915a13d45621acc4ac42660014c49` |
|
||||
| Wheel CPython 3.13, Linux x86_64 | `306499d8668ee138357f1de43a5c1c88f4dc46c845e8a92e6ad945327bcaa050` |
|
||||
|
||||
Проверка патча на реальной модели подтвердила, что worker владеет подготовленным
|
||||
состоянием. Копирование матрицы и одноразовая финализация работают корректно;
|
||||
штатный `process()` точно совпадает с цепочкой
|
||||
`Prepare -> cluster(copy) -> Finalize`. Один и два worker дали одинаковые
|
||||
строки, метки и итоговые интервалы без пропусков и дублей.
|
||||
|
||||
## Условия
|
||||
|
||||
- Intel Core i7-6820HQ, 4 физических ядра / 8 логических процессоров;
|
||||
- 32 181 526 528 байт RAM, x86_64, AVX2; VNNI и AVX-512 отсутствуют;
|
||||
- Linux 7.0.0-30-generic, Python 3.13.13;
|
||||
- `sherpa-onnx` 1.13.6, ONNX Runtime 1.28.0, NumPy 2.4.3;
|
||||
- питание от сети, системный профиль `balanced`, governor `powersave`, turbo
|
||||
включен;
|
||||
- первая попытка финального W0 отбракована из-за swap-in; повторная попытка W0
|
||||
и первая попытка C2 прошли runtime guard. В принятых попытках W0 нагрел CPU
|
||||
с 80 до 85 C, C2 - с 80 до 82 C; power limits, throttle count и swap I/O
|
||||
не изменились;
|
||||
- три контрольных фрагмента по 300 с, ожидаемые кластеры `3 / 2 / 2`;
|
||||
- один запуск каждой исследовательской ячейки и одна соседняя пара W0/C2 под
|
||||
runtime guard; ASR-sidecar подготовлены заранее и во время ячейки не учитывались.
|
||||
|
||||
Измеряемый интервал включает полную диаризацию фрагмента. Для E3 отдельно
|
||||
помечена составная оценка, потому что независимые session измерялись на заранее
|
||||
подготовленных заданиях.
|
||||
|
||||
## Идентификаторы прогона
|
||||
|
||||
- source commit: `6345cb6449d906767b6ed33e695f2ca4eeaf3008`;
|
||||
- experiment ID: `457dffbb690516a2b38ee1331a3939664ada3a0c4ade638eab6b7c5270b17f4e`;
|
||||
- capsule ID: `intel-41-5de5f454`;
|
||||
- SHA-256 scheduler output:
|
||||
`14c5aa1d3131f9bd324c77d4c3bf315772e6374835f28863c5db45fc27ab5ce5`.
|
||||
|
||||
Manifest оставляет исследовательские ячейки в матрице как уже измеренные и
|
||||
выключенные, а через scheduler повторяет только финальную соседнюю пару:
|
||||
|
||||
| Ячейка | Позиция | Cell ID | Статус в финальном плане |
|
||||
|---|---:|---|---|
|
||||
| W0 | 1 | `68360310692540e5df2640e3d653c12b91c6acaedfae56170e6ce74ecea12af2` | сырой результат сохранен |
|
||||
| W1 | 2 | `e170c9a1808c8e1c6c824381dca39495740ba2526725c280e7a9aed115bc7a36` | сырой результат сохранен |
|
||||
| W2 | 3 | `8e4283a6ae8bae62e7397e83870afd1d0706816e1e123c012d2b3ae795f4c7d4` | сырой результат сохранен |
|
||||
| W3 | 4 | `46d81148e1c3eca15e6f8c1eacce41556db90a5e07429acf1ba4292103996845` | сырой результат сохранен |
|
||||
| W4 | 5 | `eab104a305dc192847c16786de6cfa81f9a90bb9a80dc91627d439af4eb5d39d` | сырой результат сохранен |
|
||||
| M0 | 10 | `206a1a7821cb29dfe86d19e45ff665ac6f143aad3a1b79419987162097973208` | сырой результат сохранен |
|
||||
| M2 | 11 | `17559e0b25df2ca77a25d93b2238e9a726ac2ab123d11ec6ddcb9de51b298e9c` | сырой результат сохранен |
|
||||
| M3 | 12 | `c5a5d9eb45172caeb5a4d476463f0a783d6337312aab6ef7177f2a6dfcca53d2` | сырой результат сохранен |
|
||||
| Q0 | 20 | `386b2b9ba686466bf4603dda51222e7cdd6324061a68babed339db3e8e6a09dc` | сырой результат сохранен |
|
||||
| Q1 | 21 | `e1cee039ce5c65ea91004df99eaefff50b02ce1c40a8f53eba34a234522a0e3f` | сырой результат сохранен |
|
||||
| E1 | 30 | `4175101fcb0abbc6f4f46caa87273c431ce5fab61620493b13df4ed18f05f29d` | сырой результат сохранен |
|
||||
| E0 для E1 | 31 | `9f59ab52ffd188da28f94233f13a19a297568e7c1e754c2a9e09157bca10ad85` | сырой результат сохранен |
|
||||
| E0 для E2 | 32 | `c349a249896faeddc06356fd671ff2505bc2a89075ea7b2407964d6afae90232` | сырой результат сохранен |
|
||||
| E2 | 33 | `1aba4e138f3cdfd675cf411b485cfd5a01c3166d4ae893a779d88c75fa1f53c2` | сырой результат сохранен |
|
||||
| E3 | 34 | `6d2af928e2270eea37735ab39314b050fd520911cc07ba2303016ab657dd610a` | сырой результат сохранен |
|
||||
| E0 для E3 | 35 | `c55eac6d19ea7741debb02a1b602782dc0332bafe72323ea54651845416d4b6f` | сырой результат сохранен |
|
||||
| C1 | 40 | `3898cd41f6dc7d50bed245bfa6ec945c700a7fad67ac3f2e7f950d864a9ac9de` | исключена |
|
||||
| W0 для C2 | 50 | `44a6ce169f9566088376b0f6139f6823783baff481edd8741e39690534c6c47d` | выполнена, baseline |
|
||||
| C2 | 51 | `90ad304d332efa4742490f3a4a40cfb7e3422dfa85a22fbf254c7f4900b4a51f` | выполнена, handoff |
|
||||
|
||||
TitaNet batch 1/4 остался отдельным mechanism smoke и в финальную A/B-матрицу
|
||||
не входит.
|
||||
|
||||
## Этап 0: механизмы
|
||||
|
||||
`Prepare/Finalize`, общий worker и строгая схема manifest v3 прошли smoke-тесты.
|
||||
Для CAMPPlus и TitaNet на одних и тех же подготовленных матрицах сравнивались
|
||||
eigengap и NME:
|
||||
|
||||
| Модель | Строки матриц | Eigengap, N | NME, N | Выбор |
|
||||
|---|---|---|---|---|
|
||||
| CAMPPlus | `531 / 414 / 440` | `6 / 1 / 7` | `3 / 2 / 2` | NME |
|
||||
| TitaNet small | `531 / 414 / 440` | `7 / 2 / 4` | `3 / 2 / 2` | NME |
|
||||
|
||||
Ни одна матрица не потеряла строки. Повторный запуск счетчика и штатная
|
||||
финализация дали одинаковые параметры и метки.
|
||||
|
||||
WeSpeaker S8S8 QDQ построен на 48 интервалах отдельной записи. Manifest хранит
|
||||
исходный SHA-256 и точный интервал каждого элемента. Граф сохранил интерфейс,
|
||||
opset и метаданные, прошел ONNX checker, динамические входные длины в ONNX
|
||||
Runtime и загрузку через sherpa-onnx. SHA-256 графа:
|
||||
`9a3f8ae64d1083b64a6276f1e1138b17b7511a9a8d731b632945efc84098588b`.
|
||||
|
||||
## Этап 1: шаг окна
|
||||
|
||||
| Ячейка | Шаг | Время, с | Кластеры | Качество | Решение |
|
||||
|---|---:|---:|---|:---:|---|
|
||||
| W0 | 0,10 | 148,220 | `3 / 2 / 2` | да | baseline |
|
||||
| W1 | 0,15 | 100,799 | `2 / 2 / 2` | нет | исключен |
|
||||
| W2 | 0,20 | 76,127 | `2 / 2 / 2` | нет | исключен |
|
||||
| W3 | 0,25 | 61,369 | `2 / 2 / 2` | нет | исключен |
|
||||
| W4 | 0,50 | 31,291 | `1 / 2 / 2` | нет | исключен |
|
||||
|
||||
Сокращение числа окон объясняет ускорение, но все увеличенные шаги потеряли
|
||||
кластер на первой записи. Рабочим остался `0.1`; C0 совпал бы с baseline и
|
||||
поэтому не запускался.
|
||||
|
||||
## Этапы 2-3: модели и исполнение
|
||||
|
||||
| Ветвь | Время, с | Изменение к W0 | Пиковый RSS, МиБ | Кластеры | Решение |
|
||||
|---|---:|---:|---:|---|---|
|
||||
| M2, CAMPPlus + NME | 94,550 | -36,21% | 358,6 | `3 / 2 / 2` | самая быстрая; нужна слуховая проверка |
|
||||
| M3, TitaNet + NME | 107,205 | -27,67% | 511,9 | `3 / 2 / 2` | прошел, но слабее M2 |
|
||||
| QDQ WeSpeaker | 177,537 | +19,78% | 367,7 | `2 / 2 / 2` | исключен по качеству |
|
||||
| E1, shared 2 x pool 4 | 139,219 | -6,07% | 398,6 | `3 / 2 / 2` | допущен как аппаратный кандидат |
|
||||
| E2, shared 4 x 1 | 152,167 | +2,66% | 551,2 | `3 / 2 / 2` | исключен по времени и памяти |
|
||||
| E3, 4 отдельные session | 159,038 | +7,30% | 732,3 | `3 / 2 / 2` | отрицательный контроль |
|
||||
|
||||
Время E3 - составная оценка, а не прямое время штатного `process()`. RSS E3 -
|
||||
максимум всего проверочного процесса: он включает штатную подготовку,
|
||||
экспериментальные эмбеддинги и контрольную финализацию. Отдельный RSS только
|
||||
embedding-этапа не измерялся. Даже эта завышенная оценка, включающая соседние
|
||||
этапы, подтверждает ожидаемый рост памяти с числом session, поэтому E3 не стал
|
||||
кандидатом.
|
||||
|
||||
У E1 и E2 метки, итоговые интервалы и диагностические показатели побайтово
|
||||
совпали с последовательным вариантом. E1 выбран для C2 как более быстрый и
|
||||
экономный режим.
|
||||
|
||||
TitaNet batch 1 воспроизвел штатные эмбеддинги с максимальным абсолютным
|
||||
расхождением не больше `3,4e-6`. Batch 4 сохранил `3 / 2 / 2`, но поменял метки
|
||||
строк на всех трех матрицах, дал дрейф эмбеддингов `0,277-0,317` и оказался
|
||||
медленнее batch 1. Ветвь остановлена до CPU-ячейки.
|
||||
|
||||
## Этап 4: комбинации
|
||||
|
||||
| Комбинация | Рецепт | Время, с | Ускорение | RSS, МиБ | Диагностика | Handoff |
|
||||
|---|---|---:|---:|---:|---|:---:|
|
||||
| C1 | `0.1` + CAMPPlus + NME + sequential | 98,347 | 33,65% | 399,5 | purity выросла; `+2` слова без говорящего | нет |
|
||||
| C2 | `0.1` + WeSpeaker FP32 + shared 2 x pool 4 | 140,632 | 4,32% | 406,0 | точное совпадение с W0 | да |
|
||||
|
||||
Процент C1 посчитан относительно одиночного W0 из этапа окна, 148,220 с; это не
|
||||
соседняя A/B-пара. Процент C2 посчитан относительно соседнего W0 под runtime
|
||||
guard, 146,978 с.
|
||||
|
||||
У C1 `mapped speaker purity` выросла с `0,76655 / 0,78742` до
|
||||
`0,80137 / 0,91079`. Слова без говорящего изменились с `51 / 13 / 9` на
|
||||
`53 / 13 / 9`. Без слухового решения это не считается пройденной диагностикой.
|
||||
|
||||
## Закрепленные модели
|
||||
|
||||
| Идентификатор | SHA-256 |
|
||||
|---|---|
|
||||
| Pyannote FP32 | `220ad67ca923bef2fa91f2390c786097bf305bceb5e261d4af67b38e938e1079` |
|
||||
| WeSpeaker FP32 | `e9848563da86f263117134dfd7ad63c92355b37de492b55e325400c9d9c39012` |
|
||||
| WeSpeaker S8S8 QDQ | `9a3f8ae64d1083b64a6276f1e1138b17b7511a9a8d731b632945efc84098588b` |
|
||||
| CAMPPlus | `aa3cfc16963a10586a9393f5035d6d6b57e98d358b347f80c2a30bf4f00ceba2` |
|
||||
| TitaNet small | `ad4a1802485d8b34c722d2a9d04249662f2ece5d28a7a039063ca22f515a789e` |
|
||||
|
||||
## Индекс публичных доказательств
|
||||
|
||||
Хеши ниже связывают обезличенные агрегаты с сырыми файлами приватной капсулы,
|
||||
не публикуя пути, текст или интервалы.
|
||||
|
||||
| Результат | SHA-256 | Окна по записям | Embedding-задания по записям |
|
||||
|---|---|---|---|
|
||||
| fresh 1.13.5 | `c53c1f970ee4db094779bc32b42f978c37c2cb74d814504ccfac87f09e802ddd` | `291 / 291 / 291` | `531 / 414 / 440` |
|
||||
| fresh 1.13.6 | `eb7e3d508836e1eefd679a851271eaa6823d688fef39d2ae03978e7fa7006bba` | `291 / 291 / 291` | `531 / 414 / 440` |
|
||||
| W0 | `32d513cd0e81fdca0e37484f86b5c4312d3573608493972a282302dd891cdf0e` | `291 / 291 / 291` | `531 / 414 / 440` |
|
||||
| W1 | `be8935b5a81e2aadaeb38540e615c0cd3ae5ac31d6e0949856fd7d16d31a78ea` | `195 / 195 / 195` | `357 / 274 / 302` |
|
||||
| W2 | `f95433d516772d40c4071da0d0745b981314ee7a7063fb4a43518e94bc17848e` | `146 / 146 / 146` | `264 / 206 / 222` |
|
||||
| W3 | `14cac4f1d8cd38ed5b4a20189438d2b1e109ba267c5feaa36f0928fbec848580` | `117 / 117 / 117` | `211 / 165 / 175` |
|
||||
| W4 | `6fafefce00e90590ab2d834fb560413cd7780e20cbd1f631d9954ec8b97a540e` | `59 / 59 / 59` | `105 / 82 / 89` |
|
||||
| M2, исправленный интервал | `3debc8ae5cf1bd13d806dbfe8e4cfa137d6705622a721f4a42f1a22c030ec277` | `291 / 291 / 291` | `531 / 414 / 440` |
|
||||
| M3, исправленный интервал | `531a842234e35e29f582ff8a51406fb9dd6211e017b3162d80cca21d4732d316` | `291 / 291 / 291` | `531 / 414 / 440` |
|
||||
| E1, исправленный интервал | `f5ac41d29eeff1f7cfbb93497a0025aef40de0838e933e0f9baed30a236d6b85` | `291 / 291 / 291` | `531 / 414 / 440` |
|
||||
| E2, исправленный интервал | `f2664d354ede1e4bf2c42b974513cdc845ff601c73f30ebae51456764fd8dcdc` | `291 / 291 / 291` | `531 / 414 / 440` |
|
||||
| C1, исправленный интервал | `4f96e6f7363e0edb79d4034a07f4d3b6b0003a820fcf6bceff5426bb3c2caff4` | `291 / 291 / 291` | `531 / 414 / 440` |
|
||||
| W0 для C2, canonical result | `e572dd057ec87514aca4617465c48ce361677ae19279efcadbbde404afc85b5d` | `291 / 291 / 291` | `531 / 414 / 440` |
|
||||
| C2, canonical result | `d687074cc582e2ac379ed3375362f9480778feca02c7492a4c4be4fcc1a4f15a` | `291 / 291 / 291` | `531 / 414 / 440` |
|
||||
|
||||
Для W0 под runtime guard суммарное user/system CPU time равно
|
||||
`584,590 / 0,590` с,
|
||||
средний RTF по трем записям - `0,16331`. Для C1 эти значения равны
|
||||
`406,230 / 1,050` с и `0,10927`; для C2 под runtime guard -
|
||||
`667,580 / 0,960` с и
|
||||
`0,15626`. У C2 повышенное CPU time отражает конкурентную работу двух stream,
|
||||
а решение принимается по wall time.
|
||||
|
||||
Контрольные входы закреплены отдельно от результатов:
|
||||
|
||||
| Запись | SHA-256 media | SHA-256 ASR-sidecar | SHA-256 reference |
|
||||
|---|---|---|---|
|
||||
| control 1 | `1057616b42e8add00e0eb975b02bdef0ec9f6cdfec6dbf488e0c60423c9b7b87` | `3e190c060b2a9b0f4145249702d66b28c26e9b8518462000d368b431e22902bb` | - |
|
||||
| control 2 | `51866d247fe3eda134cdd884f707b1f1db8855b492e8bd14d2b56b62476255ed` | `f8ce6e25d0ce7999b2d3c31c7ca9fc5f721b8925334362729eed06552296bdb5` | `9098548743a86f868dfcdd906bcde34ed1ce5a935602cbe399f4bceda8685b3c` |
|
||||
| control 3 | `4422f04e2771091a0648e5422d14a31dd7ca2c8eef7ed9f4a5c63f43d8ca6400` | `eca10e7b232a211ce53decfc1a449c8ce2ae482dd7bc67c7aa9a267b12a2582d` | `dc2db5925c98ea7029bf52ad9cd7cc332d0084b91118a3d58336058f619d3d9a` |
|
||||
|
||||
Для CAMPPlus NME выбрал `p=111 / 54 / 104`; время счетчика составило
|
||||
`3,536 / 1,488 / 1,148` с. Для TitaNet - `p=90 / 65 / 110` и
|
||||
`1,084 / 0,780 / 1,112` с. Все шесть результатов дали ожидаемое N,
|
||||
повторяемые метки и ноль потерянных строк.
|
||||
|
||||
## Воспроизведение на Ryzen
|
||||
|
||||
После проверки commit и внешнего SHA-256 приватной капсулы:
|
||||
|
||||
```bash
|
||||
uv sync --frozen
|
||||
uv pip install --python .venv/bin/python --reinstall \
|
||||
/path/to/capsule/build/sherpa_onnx-1.13.6-cp313-cp313-linux_x86_64.whl
|
||||
export LOCAL_TRANSCRIBER_EXPERIMENT_BUILD_SHA256=\
|
||||
306499d8668ee138357f1de43a5c1c88f4dc46c845e8a92e6ad945327bcaa050
|
||||
uv run python scripts/benchmarks/diarization_calibration.py \
|
||||
--capsule /path/to/original-capsule.zip \
|
||||
--capsule-sha256 CAPSULE_SHA256_FROM_HANDOFF \
|
||||
--manifest /path/to/capsule/manifest.json \
|
||||
--validate-only
|
||||
```
|
||||
|
||||
`--validate-only` должен вернуть schema v3, этап `intel`, пустой список
|
||||
предупреждений и `qdq_available=true`. Затем Ryzen-агент создает свой manifest
|
||||
этапа `ryzen`, указывает Intel commit как `handoff_commit` и выполняет
|
||||
изолированный E1 A/B перед первичным A/B C2. Экспериментальный wheel совместим
|
||||
только с CPython 3.13 на Linux x86_64; на другой платформе его нужно пересобрать
|
||||
из патча с тем же SHA-256.
|
||||
|
||||
Приватная капсула содержит manifest, исходные записи, ASR-sidecar, опорную
|
||||
разметку, калибровочные данные, созданные модельные артефакты, сырые результаты,
|
||||
worker requests и журналы. Эти файлы не входят в Git и не передаются внешним
|
||||
ревьюерам.
|
||||
|
||||
## Ограничения
|
||||
|
||||
- У каждой ячейки один прогон. Проценты не оценивают дисперсию, прогрев и
|
||||
температурный дрейф.
|
||||
- Ранние ячейки запускались не чередующимися парами, поэтому полного A/B не
|
||||
образуют. Финальные механизмы на Ryzen следует проверять только чередующимися
|
||||
парами из спецификации.
|
||||
- Сравнение sherpa-onnx 1.13.5 и 1.13.6 показывает равенство качества, но разницу
|
||||
времени нельзя приписывать одной версии без повторов.
|
||||
- C1 нельзя возвращать в матрицу без слуховой проверки подготовленных мест.
|
||||
- Полные записи и приемка пяти слуховых категорий относятся к Ryzen-этапу после
|
||||
выбора финалиста.
|
||||
- Вывод относится к одному Intel Core i7-6820HQ без VNNI/AVX-512. Результаты
|
||||
QDQ и shared-session нельзя переносить на Ryzen без собственного A/B.
|
||||
@@ -0,0 +1,328 @@
|
||||
# CPU-диаризация: Ryzen-этап эксперимента
|
||||
|
||||
**Дата:** 2026-08-31
|
||||
**Статус:** `new map`; C2 прошла проверки качества и памяти, но не достигла
|
||||
итогового порога ускорения 30%, а более быстрые ветви Intel уперлись в качество.
|
||||
Production-профиль не менялся.
|
||||
|
||||
## Итог
|
||||
|
||||
На AMD Ryzen 7 8845H выполнен этап 5 эксперимента из
|
||||
[спецификации](../specs/2026-08-30-cpu-diarization-experiment.md) по единственному
|
||||
кандидату C2 из [Intel-этапа](2026-08-31-cpu-diarization-intel.md). Этап 6 не
|
||||
запускали: победителя нет. C2 сохраняет `window_shift_ratio=0.1`, Pyannote
|
||||
Segmentation 3.0 FP32, WeSpeaker ResNet34 LM FP32 и `FastClustering(0.89)`.
|
||||
Embedding-вызовы выполняют два outer worker через одну общую ONNX Runtime
|
||||
session с общим пулом из восьми intra-op потоков; `inter_op_threads=1`.
|
||||
|
||||
Изолированный A/B механизма shared-session дал ускорение 24,05%. Первичный A/B
|
||||
C2 показал 22,90%, поэтому кандидат прошел порог продолжения 10%, и для него
|
||||
запустили пять чередующихся циклов. Они дали медиану 23,57% при MAD 0,62
|
||||
процентного пункта, то есть ниже итогового порога 30%; по одному Ryzen-кандидату
|
||||
C2 итог был бы `stop`.
|
||||
|
||||
Вся воронка дала `new map`. W1-W4 потеряли обязательное число кластеров. C1 с
|
||||
CAMPPlus + NME ускорила Intel-прогон на 33,65%, но оставила два дополнительных
|
||||
слова без говорящего; требуемую слуховую проверку не проводили и автоматической
|
||||
оценкой не подменяли. Поэтому C1 не передавали на Ryzen и не считаем победителем.
|
||||
Вернуть ее в матрицу можно только после приватной слуховой проверки.
|
||||
|
||||
Все принятые ячейки прошли обязательное качество: `3 / 2 / 2` кластера,
|
||||
неизменный текст, `51 / 13 / 9` слов без говорящего, те же значения
|
||||
`mapped speaker purity` `0,76655 / 0,78742`, `291 / 291 / 291` segmentation-окон
|
||||
и `531 / 414 / 440` embedding-заданий. Потерь или дублей prepared-строк не было.
|
||||
Пиковый RSS C2 оставался в диапазоне 410,3-418,1 МиБ; OOM, активности swap и
|
||||
линейного роста памяти не наблюдалось.
|
||||
|
||||
Полные записи и слуховая приемка по пяти категориям на этапе 6 не запускались.
|
||||
|
||||
## Что именно проверяли
|
||||
|
||||
Отчет охватывает всю воронку: этапы 0-4 на Intel и этап 5 на Ryzen. На Intel
|
||||
проверили шаг окна, модели эмбеддингов, счетчик говорящих, квантизацию, способы
|
||||
параллельного исполнения и две комбинации. Таблицы ниже собирают этот путь в
|
||||
одном месте. Подробные хеши и исходные идентификаторы ячеек остаются в
|
||||
[Intel-отчете](2026-08-31-cpu-diarization-intel.md).
|
||||
|
||||
Intel-ячейки W/M/E/C были разведочными одиночными прогонами трех пятиминутных
|
||||
фрагментов. Они показывают направление и причину отсева, но не дисперсию.
|
||||
Исключение - соседняя пара W0/C2 под runtime guard. На Ryzen сначала выполнялась
|
||||
одна диагностическая пара, затем первичная пара C2 и только после прохождения
|
||||
порога - пять чередующихся циклов.
|
||||
|
||||
### Этап 0: пригодность механизмов
|
||||
|
||||
До дорогих CPU-ячеек проверили, что экспериментальный стенд вообще измеряет
|
||||
задуманные механизмы, не теряя строки и не меняя пользовательский результат.
|
||||
|
||||
| Гипотеза | Что проверяли | Что получилось | Решение |
|
||||
|---|---|---|---|
|
||||
| `Prepare/Finalize` | Подготовка один раз, кластеризация копии матрицы и финализация | Результат совпал со штатным `process()`; пропусков и дублей строк нет | Использовать в стенде |
|
||||
| Счетчик eigengap | CAMPPlus и TitaNet на матрицах `531 / 414 / 440` строк | CAMPPlus дал `6 / 1 / 7`, TitaNet - `7 / 2 / 4` вместо `3 / 2 / 2` | Исключить |
|
||||
| Счетчик NME | Те же шесть матриц без повторного embedding-инференса | Обе модели дали `3 / 2 / 2`; метки повторяемы, потерянных строк нет | Использовать для M2 и M3 |
|
||||
| WeSpeaker S8S8 QDQ | Схема, opset, метаданные, динамические длины и загрузка через sherpa-onnx | Граф технически корректен; качество и скорость проверены отдельно в Q1 | Допустить к CPU-ячейке |
|
||||
| Shared-session worker | Один и два worker на одном наборе prepared-входов | Строки, метки и интервалы совпали; гонок и нечисловых значений нет | Допустить E1/E2 |
|
||||
|
||||
Задача этапа 0 - подтвердить, что последующие CPU-ячейки сравнивают одинаковые
|
||||
вычисления и используют исправный экспериментальный API. Скорость измеряли на
|
||||
следующих этапах.
|
||||
|
||||
### Этап 1: можно ли считать меньше окон
|
||||
|
||||
Гипотеза: увеличить `window_shift_ratio`, сократить число segmentation-окон и
|
||||
вместе с ними число embedding-заданий. Все варианты использовали Pyannote
|
||||
Segmentation 3.0 FP32, WeSpeaker FP32 и текущий `FastClustering(0.89)`.
|
||||
|
||||
| Ячейка | Шаг | Окна по трем фрагментам | Embedding-задания | Время, с | Кластеры | Решение |
|
||||
|---|---:|---|---|---:|---|---|
|
||||
| W0 | 0,10 | `291 / 291 / 291` | `531 / 414 / 440` | 148,220 | `3 / 2 / 2` | baseline |
|
||||
| W1 | 0,15 | `195 / 195 / 195` | `357 / 274 / 302` | 100,799 | `2 / 2 / 2` | исключена: потерян кластер |
|
||||
| W2 | 0,20 | `146 / 146 / 146` | `264 / 206 / 222` | 76,127 | `2 / 2 / 2` | исключена: потерян кластер |
|
||||
| W3 | 0,25 | `117 / 117 / 117` | `211 / 165 / 175` | 61,369 | `2 / 2 / 2` | исключена: потерян кластер |
|
||||
| W4 | 0,50 | `59 / 59 / 59` | `105 / 82 / 89` | 31,291 | `1 / 2 / 2` | исключена: потеряны два кластера |
|
||||
|
||||
Скорость росла именно вместе с сокращением работы, но даже осторожный W1
|
||||
нарушил обязательное качество на первой записи. Рабочим остался шаг `0.1`.
|
||||
Комбинация C0 совпала бы с baseline, поэтому ее не запускали.
|
||||
|
||||
### Этап 2: можно ли удешевить модель эмбеддингов
|
||||
|
||||
Гипотеза: сохранить полный набор окон, но заменить WeSpeaker и при необходимости
|
||||
счетчик говорящих. В M2 и M3 сохраняли `window_shift_ratio=0.1`, Pyannote
|
||||
Segmentation 3.0 FP32 и последовательный инференс. Менялись модель эмбеддингов и
|
||||
счетчик говорящих. Проценты ниже посчитаны относительно W0 148,220 с.
|
||||
|
||||
| Ячейка | Рецепт | Время, с | Изменение | RSS, МиБ | Кластеры | Решение |
|
||||
|---|---|---:|---:|---:|---|---|
|
||||
| W0 (рецепт M0) | WeSpeaker FP32 + `FastClustering(0.89)` | 148,220 | baseline | 362,6 | `3 / 2 / 2` | точка сравнения |
|
||||
| M2 | CAMPPlus + NME + `FastClustering(N)` | 94,550 | -36,21% | 358,6 | `3 / 2 / 2` | самый быстрый, собрать C1 |
|
||||
| M3 | TitaNet small + NME + `FastClustering(N)` | 107,205 | -27,67% | 511,9 | `3 / 2 / 2` | прошла, но медленнее и тяжелее M2 |
|
||||
|
||||
M2 и M3 прошли обязательное число кластеров. Окончательная диагностика M2
|
||||
появилась в C1: purity выросла, но число слов без говорящего на первой записи
|
||||
изменилось с 51 до 53. По спецификации это требует прослушивания отмеченных мест.
|
||||
|
||||
### Этап 3: квантизация и исполнение эмбеддингов
|
||||
|
||||
Для QDQ проверяли, даст ли INT8 выигрыш старому Intel без VNNI. Q1 и E1-E3
|
||||
сохраняли `window_shift_ratio=0.1`, Pyannote Segmentation 3.0 FP32 и текущий
|
||||
`FastClustering(0.89)`. Q1 меняла модель WeSpeaker, E1-E3 - только топологию
|
||||
исполнения FP32-модели. Во всех вариантах `inter_op_threads=1`; E1-E3 использовали
|
||||
общий бюджет четырех физических ядер.
|
||||
|
||||
| Ячейка | Рецепт | Время, с | Изменение к W0 | RSS, МиБ | Кластеры | Решение |
|
||||
|---|---|---:|---:|---:|---|---|
|
||||
| Q1 | WeSpeaker S8S8 QDQ, sequential | 177,537 | +19,78% | 367,7 | `2 / 2 / 2` | исключена: медленнее и потерян кластер |
|
||||
| E1 | 2 outer worker, одна session, общий pool 4 | 139,219 | -6,07% | 398,6 | `3 / 2 / 2` | передать как аппаратный кандидат |
|
||||
| E2 | 4 outer worker, одна session, `intra_op=1` | 152,167 | +2,66% | 551,2 | `3 / 2 / 2` | исключена по времени и памяти |
|
||||
| E3 | 4 отдельные session, `intra_op=1` | 159,038 | +7,30% | 732,3 | `3 / 2 / 2` | отрицательный контроль подтвердился |
|
||||
|
||||
E3 измерялась составным интервалом на заранее подготовленных заданиях, поэтому
|
||||
ее wall time нельзя сравнивать с `process()` так же строго, как E1/E2. Для
|
||||
решения хватило отрицательного результата одновременно по времени и памяти.
|
||||
|
||||
TitaNet batch проверили отдельно на тех же трех матрицах:
|
||||
|
||||
| Контроль | Строк | Batch 1, с | Batch 4, с | Число кластеров | Метки строк совпали |
|
||||
|---:|---:|---:|---:|---:|:---:|
|
||||
| 1 | 531 | 27,175 | 37,588 | 3 | нет |
|
||||
| 2 | 414 | 25,871 | 32,443 | 2 | нет |
|
||||
| 3 | 440 | 22,877 | 28,565 | 2 | нет |
|
||||
|
||||
Batch 4 сохранил число кластеров, но оказался медленнее batch 1, поменял метки
|
||||
строк на всех трех матрицах и дал максимальный дрейф эмбеддингов `0,277-0,317`.
|
||||
Ветвь остановили до отдельной CPU-ячейки. SHA-256 закрытого batch-result:
|
||||
`f12bfdb5cadf80c62871c0725eac744a99825cb5d06b70fcf11e4e538ff6df72`.
|
||||
|
||||
### Этап 4: комбинации перед Ryzen
|
||||
|
||||
Полного декартова перебора не было. C0 совпала с baseline; из оставшихся
|
||||
собрали самый быстрый модельный вариант C1 и переносимый аппаратный вариант C2.
|
||||
|
||||
| Комбинация | Точный рецепт | Время, с | Ускорение | RSS, МиБ | Диагностика | Решение |
|
||||
|---|---|---:|---:|---:|---|---|
|
||||
| C1 | `0.1` + Pyannote Segmentation 3.0 FP32 + CAMPPlus + NME + `FastClustering(N)` + sequential | 98,347 | 33,65% | 399,5 | `3 / 2 / 2`; purity выше; `+2` слова без говорящего | не передавать без слуховой проверки |
|
||||
| C2 | `0.1` + Pyannote Segmentation 3.0 FP32 + WeSpeaker ResNet34 LM FP32 + `FastClustering(0.89)` + 2 outer worker + 1 shared session + pool 4 | 140,632 | 4,32% | 406,0 | точное совпадение с соседним W0 | передать на Ryzen |
|
||||
|
||||
У C1 процент считается от разведочного W0 148,220 с. У C2 был соседний W0
|
||||
146,978 с под тем же runtime guard. C2 не достигла Intel-порога продолжения,
|
||||
но shared-session заранее считалась аппаратной гипотезой: Ryzen имеет восемь
|
||||
физических ядер и другой ISA, поэтому спецификация требовала отдельный A/B.
|
||||
|
||||
## Handoff и воспроизводимость
|
||||
|
||||
| Объект | Значение |
|
||||
|---|---|
|
||||
| Merge commit в начале Ryzen-этапа | `68fda7ee893265acfa00ac78b8d4c9697f9916d9` |
|
||||
| Handoff commit | `42f094ccb9464742683eec9d695e5aa9a69dd4ec` |
|
||||
| Source commit эксперимента | `6345cb6449d906767b6ed33e695f2ca4eeaf3008` |
|
||||
| SHA-256 Intel-отчета | `8d4183be4c3ab35f2ea043f8d87ca20e16f523cb60f9c5414d974c1bc4d6b7de` |
|
||||
| SHA-256 входной Intel-капсулы | `a3a50d34783d24fe6cd13b154ca9813f9842fdcb08d99eab33a6a54efea1e3ae` |
|
||||
| ID закрытого Ryzen-хранилища | `ryzen-42-20260831` |
|
||||
| Upstream commit sherpa-onnx 1.13.6 | `1cb484af9b1ab05cc36f89e03dd7d76322f2e911` |
|
||||
| SHA-256 патча sherpa-onnx 1.13.6 | `5f12df858cd525c8a0cfe8ff95b647c3b0e915a13d45621acc4ac42660014c49` |
|
||||
| SHA-256 Intel-wheel | `306499d8668ee138357f1de43a5c1c88f4dc46c845e8a92e6ad945327bcaa050` |
|
||||
| SHA-256 Ryzen-wheel | `baaaa2532606f1e1411e5c84655afa70107ad4b1676420953f5e67233c46a484` |
|
||||
|
||||
Входная Intel-капсула прошла полную проверку: schema v3, 199 файлов,
|
||||
QDQ-артефакт доступен, предупреждений нет. Ryzen-manifest без изменений
|
||||
зафиксировал данные Intel-капсулы: handoff, артефакты, контрольные записи,
|
||||
calibration, QDQ, патч, версии runtime и интервал RSS. Рецепты baseline и
|
||||
кандидата во всех запущенных парах совпали с переносимыми Intel-рецептами.
|
||||
|
||||
Ryzen-manifest, сырые результаты, рабочие артефакты стенда, guard-свидетельства
|
||||
и журналы сохранены в закрытой папке Яндекс Диска. Чтобы восстановить
|
||||
Ryzen-этап, нужны это хранилище и входная Intel-капсула; публичный отчет связывает
|
||||
приведенные ниже восемь результатов с закрытым хранилищем только по SHA-256.
|
||||
|
||||
Переданный wheel не загрузился в Ryzen WSL: он требовал GLIBC 2.43, а стенд
|
||||
работает с GLIBC 2.39. Для измерений wheel пересобран из того же upstream commit
|
||||
и патча под текущую систему; его отдельный SHA-256 приведен в
|
||||
таблице. Production-зависимости и lock-файл не менялись.
|
||||
|
||||
Стенд потребовал два исправления, не меняющих пользовательский контракт. Ранее
|
||||
Ryzen-запуск сравнивал свой manifest побайтово с Intel `manifest.json` внутри
|
||||
капсулы, хотя обязательные `stage=ryzen`, Ryzen-машина и расписание такое
|
||||
совпадение исключают. Теперь Intel-manifest проверяется по индексу капсулы, а
|
||||
Ryzen-manifest отдельно связывается с handoff и входами капсулы. Второе:
|
||||
`temperature_celsius` допускает `null`, когда WSL и Windows не показывают
|
||||
датчик температуры; отсутствие измерения больше не подменяется числом.
|
||||
|
||||
## Стенд Ryzen
|
||||
|
||||
- AMD Ryzen 7 8845H with Radeon 780M, 8 физических ядер / 16 логических
|
||||
процессоров;
|
||||
- 15 584 276 480 байт RAM доступны WSL, 31 944 794 112 байт установлены в
|
||||
Windows; swap WSL - 4 294 967 296 байт, активность во время принятых ячеек -
|
||||
нулевая;
|
||||
- x86_64; AVX, AVX2, AVX-512, AVX512 VNNI, FMA, SSE4.1, SSE4.2 и AES;
|
||||
- WSL2 Linux 5.15.167.4 с GLIBC 2.39, Windows 11 Enterprise 10.0.26200,
|
||||
Python 3.13.13;
|
||||
- `sherpa-onnx` / `sherpa-onnx-core` 1.13.6, ONNX Runtime 1.28.0,
|
||||
NumPy 2.4.3, ONNX 1.22.0, kaldi-native-fbank 1.22.3, psutil 7.2.2;
|
||||
- питание от сети, стабильный Windows-профиль `balanced`, EPP AC 25%,
|
||||
максимальное состояние процессора 100%, boost mode `aggressive`;
|
||||
- WSL не предоставляет governor; доступные интерфейсы также не показывают
|
||||
температурный датчик или аппаратные лимиты мощности. Температура
|
||||
записана как недоступная; это ограничивает тепловой guard;
|
||||
- нативные WSL-интерфейсы не отдали supply, platform profile и throttle count,
|
||||
поэтому для принятых ячеек использовали override
|
||||
`LOCAL_TRANSCRIBER_RUNTIME_GUARD_JSON`, заполненный проверенными Windows-фактами:
|
||||
AC, `balanced`, turbo, power policy, нулевая подкачка, отсутствие throttling.
|
||||
До и после принятых пар Windows-проверка подтверждала AC, неизменный `balanced`
|
||||
и отсутствие событий throttling от `Microsoft-Windows-Kernel-Processor-Power`.
|
||||
Статический override сам по себе не обнаруживает изменение внутри ячейки,
|
||||
поэтому внешняя проверка остается ограничением воспроизводимости;
|
||||
- baseline: одна session, один outer worker, `intra_op_threads=8`,
|
||||
`inter_op_threads=1`, batch 1; C2: одна shared session, два outer worker,
|
||||
общий `intra_op_threads=8`, `inter_op_threads=1`, batch 1.
|
||||
|
||||
## Ячейки этапа 5
|
||||
|
||||
Время ячейки - сумма трех вызовов `diarizer.process()`, по одному на каждый
|
||||
пятиминутный фрагмент. ASR и загрузка моделей в измерение не входят.
|
||||
|
||||
| Проверка | Порядок | Baseline, с | Кандидат, с | Ускорение | RSS baseline / кандидат, МиБ | Качество |
|
||||
|---|---|---:|---:|---:|---:|:---:|
|
||||
| Совпадающий baseline | W0 | 110,998 | - | - | 370,4 / - | да |
|
||||
| Изолированный E1 | W0 -> E1 | 111,557 | 84,723 | 24,05% | 367,3 / 412,2 | да |
|
||||
| Первичный C2 | C2 -> W0 | 111,423 | 85,909 | 22,90% | 369,6 / 410,3 | да |
|
||||
|
||||
E1 и C2 сохранили все обязательные и диагностические показатели baseline.
|
||||
Первичный C2 прошел порог продолжения и уложился в правило памяти.
|
||||
|
||||
## Пять финальных A/B-циклов
|
||||
|
||||
| Цикл | Порядок | Baseline, с | C2, с | Парное ускорение | RSS baseline / C2, МиБ | Качество |
|
||||
|---:|---|---:|---:|---:|---:|:---:|
|
||||
| 1 | W0 -> C2 | 111,354 | 83,529 | 24,99% | 369,2 / 413,4 | да |
|
||||
| 2 | C2 -> W0 | 108,899 | 83,907 | 22,95% | 373,0 / 418,1 | да |
|
||||
| 3 | W0 -> C2 | 110,928 | 84,968 | 23,40% | 373,8 / 410,9 | да |
|
||||
| 4 | C2 -> W0 | 114,985 | 83,342 | 27,52% | 376,6 / 411,1 | да |
|
||||
| 5 | W0 -> C2 | 113,233 | 86,548 | 23,57% | 358,6 / 413,4 | да |
|
||||
| **Итог** | - | - | - | **медиана 23,57%; MAD 0,62 п. п.** | - | **да** |
|
||||
|
||||
Чередование не изменило вывод: даже лучший отдельный цикл остался ниже 30%, а
|
||||
медиана недобрала 6,43 процентного пункта. На Intel тот же механизм давал только
|
||||
4,32%; Ryzen ускоряет shared-session заметнее, но недостаточно для `go`.
|
||||
|
||||
## Что уже не нужно прогонять заново
|
||||
|
||||
Полный replay этапов 0-5 для следующей гипотезы не нужен. Долговечное
|
||||
доказательство составляют публичные агрегаты, хеши и
|
||||
[карта архитектур](../research/2026-09-01-end-to-end-cpu-diarization-map.md).
|
||||
Закрытые хранилища задач #41/#42 и старый материал для прослушивания C1
|
||||
временно сохраняются до завершения и приемки задачи #46 как архив только для
|
||||
чтения. Новый эксперимент их не читает. После приемки его отчета архив можно
|
||||
удалить: будущей зависимостью он не является.
|
||||
|
||||
| Результат | Что можно переиспользовать | Когда повтор действительно нужен |
|
||||
|---|---|---|
|
||||
| W1-W4 | На текущей Pyannote-модели увеличение шага теряет кластер раньше порога скорости | Изменилась segmentation-модель, логика сборки окон или обязательный quality gate |
|
||||
| M2/M3 | CAMPPlus и TitaNet с NME сохраняют `3 / 2 / 2`; M2 быстрее и экономнее | Изменилась модель, счетчик, clustering или контрольные входы |
|
||||
| Q1 | Этот S8S8 QDQ-граф на Intel медленнее FP32 и дает `2 / 2 / 2` | Новая калибровка, формат квантизации, модель или runtime |
|
||||
| E2/E3 | Больше worker и отдельные session не окупились при прежнем бюджете потоков | Изменилась топология session, runtime или бюджет ядер |
|
||||
| E1/C2 на Ryzen | Для точного рецепта shared-session медиана равна 23,57%, качество и память проходят | Изменилась реализация механизма, runtime, машина или режим питания |
|
||||
| C1 | Исторический замер скорости остается доказательством незакрытого gate; старый материал временно хранится только в архиве и не входит в задачу #46 | Для нового измерения C1 либо повторного открытия после удаления архива заново выполнить минимальную пару W0/C1 и подготовить новый материал для прослушивания |
|
||||
|
||||
Есть три разных вида проверки, и они не заменяют друг друга:
|
||||
|
||||
1. `--validate-only` сверяет commit, схему, хеши, handoff и план. Модели он не
|
||||
запускает.
|
||||
2. `uv run pytest` проверяет код стенда на mock-данных. Полный Python-suite перед
|
||||
PR нужен, но он не повторяет аудиозамеры и не скачивает модели.
|
||||
3. Измерительная ячейка запускает реальные модели. Для новой гипотезы нужен
|
||||
только затронутый минимальный срез, один совпадающий baseline и первичный A/B.
|
||||
Пять циклов и полные записи нужны лишь кандидату, прошедшему записанные пороги.
|
||||
|
||||
## Что осталось неизвестным
|
||||
|
||||
- C1 не получила слухового решения. Из автоматических метрик нельзя вывести,
|
||||
допустимы ли два дополнительных слова без говорящего.
|
||||
- Для восьми принятых последовательных W0 опубликовано
|
||||
[суммарное разложение](../research/2026-09-01-end-to-end-cpu-diarization-map.md#измеренное-разложение-текущего-ryzen-baseline)
|
||||
на segmentation, embeddings, clustering и orchestration residual. Для
|
||||
shared-session C2 schema-compatible native total и orchestration residual
|
||||
остаются недоступны.
|
||||
- Полные записи не запускались: C2 не достигла порога; C1 не дошла до Ryzen.
|
||||
- Температурный датчик в WSL и Windows недоступен. Внешняя проверка питания и
|
||||
throttling снижает риск дрейфа, но не заменяет измерение температуры во время
|
||||
каждой ячейки.
|
||||
- Эксперимент проверял локальные замены внутри прежнего каскада. End-to-end
|
||||
архитектуры в эту матрицу не входили.
|
||||
|
||||
## Индекс публичных доказательств
|
||||
|
||||
Хеши связывают обезличенные агрегаты с приватными JSON-результатами и не
|
||||
раскрывают пути, текст, интервалы и идентификаторы записей.
|
||||
|
||||
| Результат | SHA-256 |
|
||||
|---|---|
|
||||
| Совпадающий baseline | `f50684784392cacc2d99c8aee51d01f0a67dfee3da6ea3ba5f3db749a3d5287a` |
|
||||
| Изолированный E1 A/B | `24d5edae117e0619e432ef3f3019e8c66cf3516b87b80b64b8591390db25bf48` |
|
||||
| Первичный C2 A/B | `142db6d06efc8d558b38e18f47f47e3e35e7df00751da2583339f99add8d75c4` |
|
||||
| Финальный цикл 1 | `4b5095c9e64ff0ef75bbc58516c662bedbbdb0bedf131f66cc1031c9a0ad8c7f` |
|
||||
| Финальный цикл 2 | `256a17b2d08510aad03d43f5c626c1a4269c9a25271ce492dc3305c4afca1888` |
|
||||
| Финальный цикл 3 | `b5d37c7a8b05b065c61bc9d5f1c0ab639a80a821730e505179388d90c4239caa` |
|
||||
| Финальный цикл 4 | `639cc9f13511a59c7240c6fb6ec6bf1c31ec26350330e3ca0c64fb721b768736` |
|
||||
| Финальный цикл 5 | `4ff2863a93fc22a037de5c292391b6b46de1b539cf397026b349ea5134cf1051` |
|
||||
|
||||
## Решение
|
||||
|
||||
Итог - `new map`:
|
||||
|
||||
1. C2 прошла обязательное качество и память.
|
||||
2. Медианное ускорение 23,57% не достигло порога 30%.
|
||||
3. W1-W4 потеряли кластер, а C1 преодолела 30% на Intel только с незакрытым
|
||||
диагностическим слуховым gate: сокращение окон и цены эмбеддинга уперлось в
|
||||
качество раньше итогового порога.
|
||||
4. По правилу спецификации следующий шаг - отдельная карта end-to-end
|
||||
диаризации: [карта архитектур](../research/2026-09-01-end-to-end-cpu-diarization-map.md)
|
||||
и [контракт Streaming Sortformer](../specs/2026-09-01-streaming-sortformer-cpu-experiment.md).
|
||||
Карта подготовлена в
|
||||
[задаче #45](https://git.dementev.space/ddmitry/local-transcriber/issues/45),
|
||||
а контракт исполняет
|
||||
[задача #46](https://git.dementev.space/ddmitry/local-transcriber/issues/46).
|
||||
5. Полные записи и Ryzen-слуховая приемка не требуются без победителя.
|
||||
6. Production-профиль, CLI, конфигурационный контракт и модель по умолчанию
|
||||
остаются без изменений.
|
||||
@@ -0,0 +1,178 @@
|
||||
# Streaming Sortformer: разведочный прогон на Intel
|
||||
|
||||
**Дата:** 2026-09-01
|
||||
|
||||
**Статус:** разведка завершена; владелец решил не запускать Ryzen-прогон,
|
||||
production-профиль не меняется. Задача #46 формально остается открытой без
|
||||
исхода `go` или `stop`.
|
||||
|
||||
## Решение
|
||||
|
||||
Streaming Sortformer v2 Q8_0 обработал три 300-секундных фрагмента за
|
||||
137,498 с против 157,487 с у свежего W0. Ускорение одной парной проверки на
|
||||
четырех физических ядрах составило 12,69%. Это заметный выигрыш, но он далеко
|
||||
не дотягивает до целевых 30%.
|
||||
|
||||
По исходной спецификации ускорение выше 10% открывает пять финальных циклов.
|
||||
Разведка этот порог прошла. Владелец, однако, изменил вопрос: нужно было решить,
|
||||
оправдано ли неудобное развертывание отдельной машины ради шанса достичь 30%.
|
||||
|
||||
Предсказать Ryzen по этой паре нельзя. Восемь физических ядер могут сильнее
|
||||
ускорить W0, чья приемочная конфигурация получает восемь потоков. Более новая
|
||||
микроархитектура Ryzen, напротив, может оказаться выгоднее Sortformer. Модели
|
||||
переноса для этих эффектов нет. Решение владельца учитывает слабый запас по
|
||||
скорости, смешанную диагностику качества и рост памяти. Формальное правило
|
||||
остановки эксперимента здесь не применялось.
|
||||
|
||||
Кандидат не включается в production. Пять финальных циклов, полные записи и
|
||||
слуховая приемка не запускались.
|
||||
|
||||
## Статус относительно задачи #46
|
||||
|
||||
Это не канонический исход `go` или `stop` из
|
||||
[спецификации](../specs/2026-09-01-streaming-sortformer-cpu-experiment.md).
|
||||
Владелец изменил маршрут исследования после слияния стенда: вместо неудобного
|
||||
развертывания отдельной Ryzen-машины сначала потребовалась локальная проверка,
|
||||
достаточная для решения, нужен ли Ryzen вообще.
|
||||
|
||||
Разведка отличается от исходного контракта:
|
||||
|
||||
- Intel Core i7-6820HQ вместо целевого Ryzen;
|
||||
- привязка к четырем физическим ядрам `0-3` вместо восьми;
|
||||
- четыре intra-op потока W0 вместо восьми; Sortformer сохранил закрепленные
|
||||
четыре внутренних потока;
|
||||
- одна свежая пара на трех фрагментах вместо primary и пяти финальных пар;
|
||||
- использована утвержденная владельцем готовая разметка. Нового полного
|
||||
прослушивания для повторного определения числа голосов не было;
|
||||
- swap был занят до начала измерений. Во время принятой пары его объем не
|
||||
изменился, но каноническую проверку памяти нельзя считать пройденной.
|
||||
|
||||
Поэтому числа ниже отвечают только на практический вопрос о целесообразности
|
||||
дальнейшего Ryzen-прогона. Формальная задача #46 остается незавершенной.
|
||||
|
||||
Новая закрытая основа была собрана отдельно из сохраненных исходных записей.
|
||||
Подготовленные sidecar и опорная разметка перенесены по решению владельца.
|
||||
Ее `basis.json` не заменяет канонический manifest приемочного стенда. Старые
|
||||
manifest, расписания, измерения и результаты стенд не читал. Байты моделей W0
|
||||
проверены заново по закрепленным SHA-256.
|
||||
|
||||
## Рецепты
|
||||
|
||||
W0 использовал `sherpa-onnx/core 1.13.6`, Pyannote Segmentation 3.0 FP32,
|
||||
WeSpeaker ResNet34 LM FP32, `window_shift_ratio=0.1`,
|
||||
`FastClustering(auto, 0.89)`, CPU provider, `debug=True`, один процесс и четыре
|
||||
intra-op потока, `inter_op_threads=1`, `min_duration_on=0.3` и
|
||||
`min_duration_off=0.5`.
|
||||
|
||||
Кандидат использовал NeMo-Speech.cpp на закрепленном commit, модель Streaming
|
||||
Sortformer v2 Q8_0, CPU backend, один процесс и один stream. Geometry и
|
||||
post-processing полностью совпадали со спецификацией. Реальная загрузка модели
|
||||
через C ABI подтвердила четыре выходных канала и шаг 0,08 с на кадр.
|
||||
|
||||
Обе ветви получили одни и те же заранее декодированные mono/16 kHz float32
|
||||
буферы. Загрузка моделей, декодирование, чтение sidecar, вычисление метрик и
|
||||
запись результатов находились вне таймера. W0 измерял штатный
|
||||
`diarizer.process(samples)`. У кандидата таймер охватывал
|
||||
`stream_open -> stream_push -> stream_finish` и построение итоговых сегментов;
|
||||
копирование диагностической матрицы вероятностей в wall не входило.
|
||||
|
||||
## Условия прогона
|
||||
|
||||
- Intel Core i7-6820HQ, четыре физических ядра и восемь логических;
|
||||
- обе ветви привязаны к физическим ядрам `0-3`;
|
||||
- фиксированный порядок пары: W0, затем Sortformer;
|
||||
- каждая ветвь запущена новым процессом; модель загружена один раз, три
|
||||
фрагмента обработаны последовательно;
|
||||
- swap до и после каждой полной ячейки занимал 185 774 080 байт и не менялся;
|
||||
короткий `vmstat` перед парой не показал swap I/O.
|
||||
|
||||
Режим питания, governor, turbo, температуру и признаки throttling во время
|
||||
принятой пары защитная проверка не записала. Обратный порядок пары тоже не
|
||||
проверялся. Поэтому 12,69% служит только ориентиром для решения о продолжении.
|
||||
Устойчивая оценка производительности Intel не получена.
|
||||
|
||||
## Скорость и память
|
||||
|
||||
| Фрагмент | W0, с | Sortformer, с | Ускорение | Кластеры W0 | Кластеры Sortformer |
|
||||
|---:|---:|---:|---:|---:|---:|
|
||||
| 1 | 54,560 | 46,163 | 15,39% | 3 | 3 |
|
||||
| 2 | 53,524 | 46,254 | 13,58% | 2 | 2 |
|
||||
| 3 | 49,402 | 45,081 | 8,75% | 2 | 2 |
|
||||
| **Сумма** | **157,487** | **137,498** | **12,69%** | - | - |
|
||||
|
||||
Суммы рассчитаны из сырых значений до округления строк таблицы.
|
||||
|
||||
У кандидата `inference` занял 137,497047 с, построение сегментов - 0,000566 с.
|
||||
Их сумма 137,497614 с совпадает с полным `wall` до точности округления.
|
||||
|
||||
Пиковый RSS процесса составил 321,8 МиБ у W0 и 586,2 МиБ у кандидата.
|
||||
Sortformer потребовал на 264,3 МиБ, или на 82,1%, больше. После третьего
|
||||
фрагмента рост вышел на плато. Разница и процент рассчитаны из числа байтов до
|
||||
округления значений в МиБ.
|
||||
|
||||
## Автоматическое качество
|
||||
|
||||
Обе ветви на всех трех фрагментах:
|
||||
|
||||
- нашли ранее утвержденное число голосовых кластеров `3; 2; 2`;
|
||||
- сохранили текст, число и порядок слов из общего ASR-sidecar;
|
||||
- выдали конечные, положительные и упорядоченные интервалы;
|
||||
- прошли правила канонической нумерации говорящих.
|
||||
|
||||
Кандидат дополнительно прошел специальную проверку Sortformer: форма
|
||||
вероятностей `T x 4`, диапазон `[0, 1]`, метки `1..4`, метаданные кадров и
|
||||
эффективная конфигурация совпали с закрепленными значениями.
|
||||
|
||||
Диагностика не показывает однозначного улучшения качества. Показатель
|
||||
`mapped speaker purity` изменился с `0,8404; 0,7665; 0,7874` у W0 на
|
||||
`0,6477; 0,8372; 0,9201` у Sortformer: на первом фрагменте есть заметное
|
||||
ухудшение, на двух других - улучшение. Слов без назначенного говорящего стало
|
||||
92 вместо 75. Эти показатели не имеют автоматического порога, но при
|
||||
продолжении потребовали бы слуховой проверки затронутых мест.
|
||||
|
||||
## Закрепленные доказательства
|
||||
|
||||
- source commit local-transcriber:
|
||||
`f59efa51b769a4301a231fa977b8e04844331bc1`;
|
||||
- SHA-256 закрытого `basis.json`:
|
||||
`ab5ee0b191770abf69d5d17fb5cc2be169c0890e3b5b683f05d5c8b7c9ffa0d3`;
|
||||
- commit NeMo-Speech.cpp:
|
||||
`4f9676226f667d14608487df744f375db87127f8`;
|
||||
- commit submodule ggml:
|
||||
`c03b4e2bcece5134827881af90242086daf75be5`;
|
||||
- SHA-256 модели Sortformer Q8_0:
|
||||
`0679cfeb1ce356d0dea9470b31274f4bfc7eb927497d82005483770666da998a`;
|
||||
- SHA-256 модели Pyannote Segmentation 3.0 FP32:
|
||||
`220ad67ca923bef2fa91f2390c786097bf305bceb5e261d4af67b38e938e1079`;
|
||||
- SHA-256 модели WeSpeaker ResNet34 LM FP32:
|
||||
`e9848563da86f263117134dfd7ad63c92355b37de492b55e325400c9d9c39012`;
|
||||
- SHA-256 исполняемого файла:
|
||||
`571ca0be2342a0ee7a807e2bdb55fda0f5e6cfa510db28ae4f5b127613889053`;
|
||||
- SHA-256 библиотеки C ABI:
|
||||
`620ad4d76906c7af744b1eabba6f961ae82e8113e404099a91cf1d8536f08122`;
|
||||
- SHA-256 приватного runner-скрипта:
|
||||
`a015a78cbed767e861eda1b52496d9da9a54cf2ba3cba0b3bc11ee89022017d9`;
|
||||
- SHA-256 закрытого результата W0:
|
||||
`d921c0160d3b79b073f1c631f87aa2f8798e619d936f13369d85656ebf3c75be`;
|
||||
- SHA-256 закрытого результата кандидата:
|
||||
`ff5a0c7f49faf64e2736ebc8075bb80dbfb85fadd4d29caeffbb3b2beadddbd3`;
|
||||
- SHA-256 сводного закрытого результата:
|
||||
`826cda109d05b72cf37af0d92101d67ffa24edd96ab78ce67abba737f73ba5ca`.
|
||||
|
||||
Закрытое хранилище содержит подготовленные записи, sidecar, опорную разметку,
|
||||
сырые результаты и воспроизводимые пины. Пути, идентификаторы записей, текст,
|
||||
интервалы, логи и аудио в отчет не включены.
|
||||
|
||||
## Ограничения
|
||||
|
||||
Разведка не доказывает скорость на Ryzen и не заменяет расписание приемочного
|
||||
эксперимента. Один порядок одной пары и неполная защитная проверка не дают
|
||||
устойчивой оценки разброса, питания и нагрева. Память не получила канонического
|
||||
вердикта.
|
||||
|
||||
Сохраняются ограничения самой модели: не больше четырех выходных голосовых
|
||||
кластеров, риск для встреч с пятью и более голосами, русской речи вне домена и
|
||||
длинных записей. Если когда-нибудь понадобится именно формальный исход #46,
|
||||
потребуется отдельный Ryzen-прогон по исходной спецификации. Intel-разведка не
|
||||
позволяет предсказать его результат; сейчас владелец решил не тратить на него
|
||||
время.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Streaming Sortformer: сравнение на Ryzen
|
||||
|
||||
**Дата:** 2026-09-04
|
||||
|
||||
Streaming Sortformer обработал три 300-секундных фрагмента за `67,433` с
|
||||
против `103,242` с у свежего W0. Ускорение одной пары составило `34,68%`.
|
||||
Кандидат правильно определил число голосов на всех трех фрагментах; W0 на
|
||||
одном из них выделил два кластера вместо трех.
|
||||
|
||||
У результата есть два заметных минуса. Sortformer оставил без говорящего
|
||||
`92` слова против `73` у W0 и потребовал около `551` МиБ памяти против `325`
|
||||
МиБ. Во время обеих ячеек была небольшая активность swap, поэтому одна пара
|
||||
показывает направление, но не устойчивость ускорения.
|
||||
|
||||
## Сравнение
|
||||
|
||||
| Показатель | W0 | Sortformer |
|
||||
| --- | ---: | ---: |
|
||||
| Суммарный wall, с | `103,242` | `67,433` |
|
||||
| Ускорение | - | `34,68%` |
|
||||
| Ожидалось кластеров | `2 / 2 / 3` | `2 / 2 / 3` |
|
||||
| Получено кластеров | `2 / 2 / 2` | `2 / 2 / 3` |
|
||||
| Ошибок по числу кластеров | `1 из 3` | `0 из 3` |
|
||||
| Mapped purity на двух размеченных фрагментах | `0,7655 / 0,8915` | `0,8392 / 0,9176` |
|
||||
| Слов без говорящего | `73` | `92` |
|
||||
| Пиковый RSS, МиБ | `324,57` | `551,07` |
|
||||
| CPU time, с | `822,67` | `238,70` |
|
||||
| Прочитано из swap, КиБ | `492` | `224` |
|
||||
| Записано в swap, КиБ | `148` | `160` |
|
||||
|
||||
Обе модели получили одинаковые заранее подготовленные mono/16 kHz float32
|
||||
буферы и те же ASR-sidecar. W0 работал на восьми физических ядрах, Sortformer -
|
||||
с четырьмя закрепленными внутренними потоками. Загрузка моделей, декодирование
|
||||
аудио, чтение sidecar и расчет метрик в wall не входили.
|
||||
|
||||
Sortformer прошел автоматические проверки формы вероятностей, диапазона
|
||||
значений, меток говорящих, текста, порядка слов и интервалов на всех трех
|
||||
фрагментах. Его mapped purity выше W0 на обоих фрагментах с опорной разметкой.
|
||||
При этом рост числа слов без говорящего требует слуховой проверки: по одной
|
||||
автоматике нельзя заключить, что качество кандидата в целом лучше.
|
||||
|
||||
## Как читать результат
|
||||
|
||||
По исходному расписанию задача завершилась как `stop`, когда W0 не прошел
|
||||
проверку числа кластеров. После этого была выполнена одна диагностическая
|
||||
ячейка Sortformer на тех же входах, чтобы ответить на практический вопрос о
|
||||
кандидате. Пять повторных пар, полные записи и слуховая проверка не выполнялись.
|
||||
|
||||
По этой паре Sortformer выглядит перспективно: ускорение оказалось выше
|
||||
целевого порога `30%`, модель правильно выделила три голоса на проблемном
|
||||
фрагменте и показала лучшую mapped purity. Для решения о внедрении нужны
|
||||
повторные парные замеры без активности swap и слуховая проверка мест, где
|
||||
слова остались без говорящего. Текущий результат уже достаточен, чтобы не
|
||||
отбрасывать кандидата и продолжить исследование отдельной задачей.
|
||||
|
||||
## Рецепты и техническая привязка
|
||||
|
||||
W0 использовал `sherpa-onnx/core 1.13.6`, Pyannote Segmentation 3.0 FP32,
|
||||
WeSpeaker ResNet34 LM FP32, CPU provider, `window_shift_ratio=0.1`,
|
||||
`FastClustering(auto, 0.89)`, один процесс и одну сессию,
|
||||
`intra_op_threads=8`, `inter_op_threads=1`, `min_duration_on=0.3` и
|
||||
`min_duration_off=0.5`.
|
||||
|
||||
Кандидат использовал NeMo-Speech.cpp commit
|
||||
`4f9676226f667d14608487df744f375db87127f8`, модель Streaming Sortformer v2
|
||||
Q8_0 с SHA-256
|
||||
`0679cfeb1ce356d0dea9470b31274f4bfc7eb927497d82005483770666da998a`, CPU
|
||||
backend, один процесс и один stream. Geometry и post-processing соответствовали
|
||||
[спецификации](../specs/2026-09-01-streaming-sortformer-cpu-experiment.md).
|
||||
|
||||
Коммит исходного кода, выполнившего обе ячейки:
|
||||
`4c4a8be07b3653cede35b0435e6c9ef6cb3fb87d`.
|
||||
|
||||
Закрытая основа и результаты сохраняются для следующих исследований. Их
|
||||
повторное использование должно быть явно описано в новой задаче.
|
||||
+25
-26
@@ -2,7 +2,7 @@
|
||||
|
||||
## Режимы `--device`
|
||||
|
||||
- `auto` (по умолчанию) — CUDA при наличии `nvidia-smi`, иначе ONNX на CPU
|
||||
- `auto` (по умолчанию) — всегда ONNX на CPU
|
||||
- `cuda` — строго NVIDIA GPU, ошибка если недоступен
|
||||
- `openvino` — авто-выбор OpenVINO GPU или CPU
|
||||
- `openvino-gpu` — строго Intel GPU через OpenVINO
|
||||
@@ -13,8 +13,8 @@
|
||||
|
||||
| Оборудование | Рекомендуемый `--device` | Бэкенд | Ожидаемая скорость |
|
||||
|---|---|---|---|
|
||||
| NVIDIA GPU (6+ GB VRAM) | `auto` / `cuda` | faster-whisper (CTranslate2) | 7-19x реалтайм |
|
||||
| Любой CPU без NVIDIA | `auto` / `onnx` | ONNX GigaAM RNN-T | 10-14x реалтайм* |
|
||||
| NVIDIA GPU (6+ GB VRAM) | `cuda` + extra | faster-whisper (CTranslate2) | 7-19x реалтайм |
|
||||
| Любой CPU | `auto` / `onnx` | ONNX GigaAM RNN-T | 10-14x реалтайм* |
|
||||
| Intel Arc iGPU / dGPU | `openvino-gpu` | OpenVINO GenAI (GPU) | TBD |
|
||||
| Intel/AMD x86 CPU | `openvino-cpu` | OpenVINO GenAI (CPU) | 3-10x реалтайм* |
|
||||
| Любой CPU, FasterWhisper | `cpu` | faster-whisper (CTranslate2) | ~1.5x реалтайм |
|
||||
@@ -22,7 +22,7 @@
|
||||
\* По результатам контрольных прогонов на Intel и AMD CPU. Реальная скорость
|
||||
зависит от CPU, модели и записи.
|
||||
|
||||
Автоматический профиль без NVIDIA рассчитан на русскую речь: GigaAM других
|
||||
Автоматический профиль рассчитан на русскую речь: GigaAM других
|
||||
языков не понимает. Для них берите Whisper — `openvino-cpu` на x86 или `cpu`
|
||||
на любой платформе.
|
||||
|
||||
@@ -211,22 +211,16 @@ SMT/Hyper-Threading не помогает — 16 потоков на 8-ядер
|
||||
|
||||
## Настройка по платформам
|
||||
|
||||
### Linux / WSL2 (x86_64)
|
||||
Для Linux/WSL x86_64 и Windows x64 используется один extra `cuda` и явный
|
||||
выбор `--device cuda` либо `device = "cuda"` в TOML. Команды установки из Git,
|
||||
клона и для разработки приведены в [README](../README.md#подключение-cuda).
|
||||
Обычный запуск всегда использует ONNX CPU, независимо от драйвера и extra.
|
||||
|
||||
Библиотека cuBLAS ставится автоматически (зависимость `nvidia-cublas-cu12` подтягивается при установке).
|
||||
Дополнительных шагов не требуется.
|
||||
|
||||
> На ARM (aarch64) cuBLAS через pip недоступен — нужен системный CUDA toolkit.
|
||||
|
||||
### Windows
|
||||
|
||||
Нужен системный **CUDA 12** (ctranslate2 4.7 не совместим с CUDA 11 и 13):
|
||||
|
||||
```bash
|
||||
winget install -e --id Nvidia.CUDA --version 12.9 # требует запуска от имени администратора
|
||||
```
|
||||
|
||||
После установки перезапустите терминал.
|
||||
Драйвер NVIDIA устанавливается отдельно. Полный системный CUDA Toolkit не
|
||||
нужен для пути через extra; на Windows требуется Visual C++ Runtime x64.
|
||||
Состав Windows DLL проверен статически; реальная транскрипция без Toolkit
|
||||
пока не проверена. Версии и границы проверки — [ADR-001](adr/001-cuda-bootstrap.md).
|
||||
CUDA extra для ARM в этой задаче не поддерживается.
|
||||
|
||||
## Совместимость GPU
|
||||
|
||||
@@ -331,14 +325,19 @@ threshold не помогла — проблема inherent для модели
|
||||
Убедитесь, что `nvidia-smi` возвращает информацию о GPU. Драйвер NVIDIA предоставляет `libcuda.so.1`,
|
||||
без которого CUDA не работает — его нельзя поставить через pip.
|
||||
|
||||
### Windows: ошибка при загрузке модели на GPU
|
||||
### Отсутствующие библиотеки и несовместимый GPU
|
||||
|
||||
GPU на Windows требует **CUDA 12** (ctranslate2 4.7 не совместим с CUDA 11 и 13). Установите:
|
||||
```bash
|
||||
winget install -e --id Nvidia.CUDA --version 12.9 # требует запуска от имени администратора
|
||||
```
|
||||
После установки перезапустите терминал.
|
||||
При отсутствии cuBLAS подключите extra в том окружении, откуда запускается
|
||||
программа, по [инструкции README](../README.md#подключение-cuda).
|
||||
На Windows ошибка загрузки DLL может также означать отсутствие Visual C++ Runtime.
|
||||
|
||||
`no kernel image` / `invalid device function` означают несовместимость GPU
|
||||
с выбранной сборкой runtime. Установка extra её не исправит. Ошибки драйвера
|
||||
требуют проверки драйвера; нехватка VRAM — меньшей модели или явного выбора CPU.
|
||||
Исходная ошибка сохраняется. CLI/TOML с явной CUDA не переходят молча на CPU
|
||||
ни при загрузке, ни при распознавании.
|
||||
|
||||
### ARM (aarch64)
|
||||
|
||||
cuBLAS через pip недоступен на ARM — нужен системный CUDA toolkit.
|
||||
CUDA extra не поддерживается проектом на этой архитектуре. Наличие отдельных
|
||||
NVIDIA wheels для ARM само по себе не подтверждает рабочий путь транскрипции.
|
||||
@@ -0,0 +1,353 @@
|
||||
# Исследование: ускорение на AMD Radeon / NPU
|
||||
|
||||
**Дата**: 2026-03-23
|
||||
**Железо**: AMD Ryzen 7 8845H, Radeon 780M iGPU (RDNA3, 12 CU), XDNA 1 NPU (16 TOPS)
|
||||
|
||||
**Статус:** архив. Перенесено из ветки `feature/amd-acceleration` (март 2026) ради истории решений. Выводы не реализованы: ADR-003 выбрал OpenVINO GenAI вместо whisper.cpp, а автоматический выбор устройства закреплён за ONNX CPU (#2).
|
||||
|
||||
## Мотивация
|
||||
|
||||
Бенчмарк ([2026-03-23-transcription-quality-ryzen.md](../benchmarks/2026-03-23-transcription-quality-ryzen.md)) показал: лучшее качество
|
||||
даёт CTranslate2 float32/int8_float32, но это 600–880с на 15-минутную запись.
|
||||
OpenVINO fp16 быстрее (240с), но теряет качество на русской речи.
|
||||
|
||||
Нужен путь: качество float32 + скорость GPU.
|
||||
|
||||
## Вариант 1: DirectML (onnxruntime-directml)
|
||||
|
||||
### Вердикт: ❌ НЕ рекомендуется
|
||||
|
||||
**Блокирующая проблема**: оператор `DecoderMaskedMultiHeadAttention` не реализован
|
||||
в DirectML ([Olive#1213](https://github.com/microsoft/Olive/issues/1213), открыт с июня 2024).
|
||||
Whisper-декодер fallback-ится на CPU — GPU ускоряет только encoder, выигрыш минимален.
|
||||
|
||||
**DirectML в maintenance mode** — Microsoft переключился на Windows ML (WinML).
|
||||
Команда DML расформирована, новых операторов не будет. Фикс маловероятен.
|
||||
|
||||
**WinML** (замена DirectML) — GA с сентября 2025, но Python-пакет `onnxruntime-winml`
|
||||
ещё не доступен. Требует Windows 11 25H2+. Потенциально интересен в будущем.
|
||||
|
||||
## Вариант 2: whisper.cpp + Vulkan ✅
|
||||
|
||||
### Вердикт: рекомендуется как основной путь
|
||||
|
||||
**Почему:**
|
||||
- Проверен на AMD Radeon 780M — баг softmax ([#2596](https://github.com/ggml-org/whisper.cpp/issues/2596))
|
||||
исправлен в декабре 2024
|
||||
- **3–4x ускорение** vs CPU на Radeon 680M (RDNA2), 780M (RDNA3) должен быть не хуже
|
||||
- Весь pipeline внутри: аудио → mel → encoder → decoder → beam search
|
||||
- GGUF-модели компактнее ONNX
|
||||
- Активная разработка, Vulkan-бэкенд зрелый
|
||||
|
||||
**Бенчмарки (чужие, похожее железо):**
|
||||
|
||||
| GPU | Модель | Результат |
|
||||
|-----|--------|-----------|
|
||||
| Radeon 680M (RDNA2) | medium | ~3–4x быстрее CPU |
|
||||
| Ryzen 5 4500U (Vega) | medium | 1ч аудио за ~25 мин |
|
||||
| Ryzen AI Max+ 395 (Strix Halo) | large-v3-turbo | 1ч20м аудио за ~3 мин |
|
||||
|
||||
**Память:** Radeon 780M использует системную RAM. whisper-medium в GGUF ≈ 1.5 ГБ —
|
||||
помещается с запасом.
|
||||
|
||||
### Интеграция в проект
|
||||
|
||||
Новый бэкенд `backends/whisper_cpp.py`, реализующий Backend Protocol:
|
||||
|
||||
```
|
||||
uv add pywhispercpp # Python-биндинги к whisper.cpp
|
||||
```
|
||||
|
||||
Либо Vulkan-сборка whisper.cpp как subprocess (более надёжно,
|
||||
но менее удобно для пользователя).
|
||||
|
||||
**Зависимость в pyproject.toml:**
|
||||
```toml
|
||||
# Опциональная, т.к. требует Vulkan-совместимый GPU
|
||||
"pywhispercpp>=0.4; sys_platform == 'win32'"
|
||||
```
|
||||
|
||||
Либо через dependency group:
|
||||
```toml
|
||||
[dependency-groups]
|
||||
vulkan = ["pywhispercpp>=0.4"]
|
||||
```
|
||||
|
||||
Тогда установка: `uv sync --group vulkan`
|
||||
|
||||
**Открытые вопросы:**
|
||||
- pywhispercpp собран с Vulkan или нужна своя сборка?
|
||||
- Качество GGUF-квантизации vs CTranslate2 float32 — нужен бенчмарк
|
||||
- Формат моделей другой (GGUF vs CTranslate2/OpenVINO) — ещё одно скачивание
|
||||
|
||||
## Вариант 3: AMD NPU (XDNA)
|
||||
|
||||
### Вердикт: ⏳ рано для XDNA 1, перспективно для XDNA 2
|
||||
|
||||
**Что есть:**
|
||||
- Ryzen AI Software 1.7.0 (февраль 2026) поддерживает 8845H (Hawk Point)
|
||||
- [AMD LIRA](https://github.com/amd/LIRA) — CLI для ASR на NPU, поддерживает whisper base/small/medium
|
||||
- [AMD-форк whisper.cpp](https://github.com/amd/whisper.cpp) с NPU-бэкендом
|
||||
- Pre-квантизированные модели на HuggingFace (`amd/NPU-Whisper-Base-Small`)
|
||||
|
||||
**Ограничения на XDNA 1 (8845H):**
|
||||
- 16 TOPS — в 3 раза слабее XDNA 2 (50 TOPS)
|
||||
- **Только encoder** на NPU, decoder на CPU — ускорение частичное
|
||||
- whisper-large-v3 не помещается
|
||||
- Установка через отдельный installer AMD, не pip/uv
|
||||
- Форк whisper.cpp не в mainline — риск отставания
|
||||
|
||||
**Когда станет интересно:**
|
||||
- На железе с XDNA 2 (Ryzen AI 300+, 50 TOPS)
|
||||
- Когда AMD выпустит pip-пакет или upstreamит в whisper.cpp
|
||||
- Для сценариев, где CPU/GPU заняты другой работой
|
||||
|
||||
## Сводка
|
||||
|
||||
| Подход | Применимость | Ускорение | Сложность | Стабильность |
|
||||
|--------|-------------|-----------|-----------|--------------|
|
||||
| **whisper.cpp + Vulkan** | ✅ высокая | 3–4x vs CPU | низкая | хорошая |
|
||||
| DirectML (onnxruntime) | ❌ заблокирован | — | — | — |
|
||||
| AMD NPU (XDNA 1) | ⚠️ ограничен | частичное | высокая | средняя |
|
||||
| WinML (будущее) | ⏳ не готов | неизвестно | средняя | — |
|
||||
|
||||
## Детали по whisper.cpp + Vulkan
|
||||
|
||||
### Модели (GGML .bin формат, НЕ GGUF)
|
||||
|
||||
Скачивать с https://huggingface.co/ggerganov/whisper.cpp/tree/main
|
||||
|
||||
| Модель | Файл | Размер |
|
||||
|--------|------|--------|
|
||||
| medium f16 | `ggml-medium.bin` | 1.53 ГБ |
|
||||
| medium q8_0 | `ggml-medium-q8_0.bin` | 823 МБ |
|
||||
| medium q5_0 | `ggml-medium-q5_0.bin` | 539 МБ |
|
||||
| large-v3 f16 | `ggml-large-v3.bin` | 3.1 ГБ |
|
||||
| large-v3 q5_0 | `ggml-large-v3-q5_0.bin` | 1.08 ГБ |
|
||||
| large-v3-turbo f16 | `ggml-large-v3-turbo.bin` | 1.62 ГБ |
|
||||
| large-v3-turbo q8_0 | `ggml-large-v3-turbo-q8_0.bin` | 874 МБ |
|
||||
|
||||
### Python-биндинги
|
||||
|
||||
**pywhispercpp** (v1.4.1) — pip wheel CPU-only. Для Vulkan нужна сборка из исходников:
|
||||
```bash
|
||||
GGML_VULKAN=1 pip install git+https://github.com/absadiki/pywhispercpp
|
||||
```
|
||||
Требует: VulkanSDK, cmake, C++ компилятор.
|
||||
|
||||
### Subprocess-подход (альтернатива)
|
||||
|
||||
whisper-cli принимает WAV 16kHz mono, выдаёт JSON с таймкодами:
|
||||
```bash
|
||||
whisper-cli -m ggml-medium.bin -f audio.wav -l ru -oj -of output
|
||||
```
|
||||
|
||||
Для видео/mp3 нужна предварительная конвертация через ffmpeg (PyAV уже есть в зависимостях).
|
||||
|
||||
### Сборка whisper.cpp с Vulkan
|
||||
|
||||
```bash
|
||||
# Linux/WSL
|
||||
sudo apt install cmake build-essential libvulkan-dev
|
||||
git clone https://github.com/ggml-org/whisper.cpp
|
||||
cd whisper.cpp
|
||||
cmake -B build -DGGML_VULKAN=ON -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build build --config Release -j$(nproc)
|
||||
# -> build/bin/whisper-cli
|
||||
```
|
||||
|
||||
## Прототип: whisper.cpp в WSL2 (2026-03-23)
|
||||
|
||||
### Сборка
|
||||
|
||||
whisper.cpp собран с `-DGGML_VULKAN=ON`, но **Vulkan GPU не обнаружен**:
|
||||
|
||||
```
|
||||
ggml_vulkan: No devices found.
|
||||
whisper_backend_init_gpu: no GPU found
|
||||
```
|
||||
|
||||
**Причина**: WSL2 не предоставляет Vulkan-доступ к AMD GPU.
|
||||
- `/dev/dxg` есть (DirectX 12 paravirtualization), но **dozen** (Vulkan-over-D3D12) драйвер
|
||||
отсутствует в текущей mesa (25.2.8)
|
||||
- RADV (нативный Vulkan) не работает — нет `/dev/dri/renderD128`
|
||||
- Единственный Vulkan-девайс — `llvmpipe` (CPU-эмуляция), whisper.cpp его игнорирует
|
||||
|
||||
Whisper.cpp отработал **на CPU** (4 потока, AVX512).
|
||||
|
||||
### Скорость (CPU fallback)
|
||||
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| Модель | ggml-medium.bin (f16, 1533 МБ) |
|
||||
| Аудио | 14:41 (881с), русский, 3 участника |
|
||||
| Общее время | **886с** |
|
||||
| encode | 368с (32 прохода, 11.5с/проход) |
|
||||
| batchd (decode) | 424с |
|
||||
| Потоки | 4 / 16 |
|
||||
| Beam search | 5 beams + best of 5 |
|
||||
|
||||
Сравнение с бенчмарком (тот же аудиофайл):
|
||||
|
||||
| Конфигурация | Время, с |
|
||||
|---|---|
|
||||
| OpenVINO fp16 (medium) | ~240 |
|
||||
| CT2 int8_float32 (medium) | ~599 |
|
||||
| CT2 float32 (medium) | ~881 |
|
||||
| **whisper.cpp CPU (medium f16)** | **~886** |
|
||||
|
||||
На CPU whisper.cpp ≈ CTranslate2 float32. Без GPU ускорения нет.
|
||||
|
||||
### Качество (сравнение с бенчмарком)
|
||||
|
||||
#### Техтермины
|
||||
|
||||
| Термин | whisper.cpp (CPU) | CT2 f32 | CT2 int8f32 |
|
||||
|--------|------------------|---------|-------------|
|
||||
| DWH | ✅ DWH | ⚠️ ДВХ | ✅ DWH |
|
||||
| Teradata | ✅ TeraData | ⚠️ Тирадату | ✅ Teradata |
|
||||
| NiFi | ⚠️ Найфай/Найфаю | ⚠️ Найфай | ✅ NiFi |
|
||||
| FineBI | ✅ fineBI/FNBI | ✅ FNBI | ✅ finebi/FNBI |
|
||||
| Alation | ✅ Allation | ✅ Allation | ✅ Allation |
|
||||
| ETL | ✅ ETL | ✅ ETL | ✅ ETL |
|
||||
| DBC Tables V | ✅ DBC tables-V | ✅ | ✅ |
|
||||
| OpenLineage | ✅ OpenLinage.io | ✅ OpenLinage.io | ✅ OpenLinage.io |
|
||||
|
||||
#### Русская разговорная речь
|
||||
|
||||
| Фраза | whisper.cpp (CPU) | CT2 f32 | CT2 int8f32 |
|
||||
|-------|------------------|---------|-------------|
|
||||
| "на другую мониторочку" | ✅ "на другом мониторчике" | ✅ "мониторчику" | ⚠️ "мою точку" |
|
||||
| "верхнеуровневое" | ✅ | ✅ | ✅ |
|
||||
| "датарентген" | ✅ "даторентген" | ✅ "даторентген" | ✅ "даторентген" |
|
||||
| "девовские схемы" | ✅ | ✅ | ✅ |
|
||||
| "с накиданными правами" | ✅ | ✅ | ✅ |
|
||||
| "Вьюхи" | ⚠️ "Yuhi" | ✅ "Вьюхи" | ⚠️ "Yuhi" |
|
||||
|
||||
#### Структура и пунктуация
|
||||
|
||||
| Аспект | whisper.cpp (CPU) | CT2 f32 | CT2 int8f32 |
|
||||
|--------|------------------|---------|-------------|
|
||||
| Пунктуация | хорошая | хорошая | хорошая |
|
||||
| Длина сегментов | 1–10 сек | ~1 мин | ~1 мин |
|
||||
|
||||
**Вывод по качеству**: whisper.cpp medium f16 на уровне CTranslate2 float32/int8_float32.
|
||||
Качество сопоставимо, различия минимальны. Сегменты короче (1–10 сек vs ~1 мин) —
|
||||
это даже лучше для навигации по транскрипту.
|
||||
|
||||
### Блокер: Vulkan в WSL2 для AMD
|
||||
|
||||
Для получения GPU-ускорения нужно одно из:
|
||||
|
||||
1. **Нативный Linux** (dual boot / bare metal) — RADV драйвер работает с Radeon 780M
|
||||
2. **Windows-сборка** — нативный Vulkan через AMD Adrenalin драйвер
|
||||
3. **WSL2 + dozen** — экспериментальный Vulkan-over-D3D12 драйвер mesa.
|
||||
Статус: [в разработке](https://gitlab.freedesktop.org/mesa/mesa/-/merge_requests?search=dozen),
|
||||
не включён в стандартную mesa Ubuntu.
|
||||
Потенциально можно собрать mesa из исходников с `-Dvulkan-drivers=microsoft-experimental`
|
||||
|
||||
**Рекомендация**: попробовать Windows-сборку whisper.cpp — это самый простой путь
|
||||
к Vulkan + Radeon 780M. Либо собрать mesa с dozen для WSL2.
|
||||
|
||||
## Прототип: whisper.cpp + Vulkan на Windows (2026-03-23)
|
||||
|
||||
### Сборка
|
||||
|
||||
whisper.cpp собран на Windows с MinGW (MSYS2) + Vulkan:
|
||||
```
|
||||
cmake -B build-vulkan -G "MinGW Makefiles" -DGGML_VULKAN=ON -DCMAKE_BUILD_TYPE=Release
|
||||
```
|
||||
|
||||
Vulkan GPU обнаружен:
|
||||
```
|
||||
ggml_vulkan: Found 1 Vulkan devices:
|
||||
ggml_vulkan: 0 = AMD Radeon(TM) 780M (AMD proprietary driver) | uma: 1 | fp16: 1 |
|
||||
warp size: 64 | shared memory: 32768 | int dot: 1 | matrix cores: KHR_coopmat
|
||||
```
|
||||
|
||||
### Проблема: зацикливание (repetition loop)
|
||||
|
||||
Первый запуск (дефолтные параметры): **520с**, но с ~07:00 модель зациклилась
|
||||
на фразе "Ну, опять же, они могут завести…" — повторяется ~170 раз до конца записи.
|
||||
Потеряна половина транскрипта.
|
||||
|
||||
**Причина**: whisper.cpp передаёт контекст предыдущего сегмента в декодер (`-mc -1`),
|
||||
что провоцирует repetition loop на длинных записях с паузами/перебивками.
|
||||
|
||||
**Решение**: `-mc 0` (не передавать контекст между сегментами).
|
||||
|
||||
### Скорость (Vulkan, -mc 0)
|
||||
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| Модель | ggml-medium.bin (f16, 1533 МБ) на Vulkan GPU |
|
||||
| Аудио | 14:41 (881с), русский, 3 участника |
|
||||
| Общее время | **432с** |
|
||||
| encode | 123.5с (35 проходов, 3.5с/проход) |
|
||||
| batchd (decode) | 252.7с |
|
||||
| Потоки | 4 / 16 |
|
||||
|
||||
Сравнение с бенчмарком (тот же аудиофайл):
|
||||
|
||||
| Конфигурация | Время, с | Ускорение vs CPU f32 |
|
||||
|---|---|---|
|
||||
| OpenVINO fp16 (medium) | ~240 | 3.7x |
|
||||
| **whisper.cpp Vulkan + Radeon 780M** | **~432** | **2.0x** |
|
||||
| **CT2 int8_float32, 8 threads** | **~458** | **1.9x** |
|
||||
| CT2 int8_float32, 16 threads | ~498 | 1.8x |
|
||||
| CT2 int8_float32, default (4 threads) | ~599 | 1.5x |
|
||||
| CT2 float32 / whisper.cpp CPU | ~881-886 | 1.0x |
|
||||
|
||||
**Оптимизация потоков**: CTranslate2 по умолчанию использует 4 потока (`cpu_threads=0`).
|
||||
На Ryzen 7 8845H (8 ядер / 16 потоков) оптимум — **8 потоков** (по числу физических ядер).
|
||||
16 потоков (SMT/hyperthreading) не помогают — контекст-свитчинг нивелирует выигрыш.
|
||||
Управление: `OMP_NUM_THREADS=8` или параметр `cpu_threads` в WhisperModel.
|
||||
|
||||
CT2 int8_float32 с 8 потоками (**458с**) почти догоняет whisper.cpp Vulkan (**432с**)
|
||||
без дополнительных зависимостей.
|
||||
|
||||
### Качество (Vulkan, -mc 0)
|
||||
|
||||
#### Техтермины
|
||||
|
||||
| Термин | whisper.cpp Vulkan | CT2 int8f32 |
|
||||
|--------|-------------------|-------------|
|
||||
| DWH | ✅ DWH | ✅ DWH |
|
||||
| Teradata | ✅ TeraData | ✅ Teradata |
|
||||
| NiFi | ⚠️ IFI | ✅ NiFi |
|
||||
| FineBI | ✅ FindBI/FineBI/FNBI | ✅ finebi/FNBI |
|
||||
| Alation | ✅ Allation | ✅ Allation |
|
||||
| ETL | ✅ ETL | ✅ ETL |
|
||||
| DBC Tables V | ⚠️ dbc tables v (lowercase) | ✅ DBC Tables V |
|
||||
| OpenLineage | ✅ OpenLinage.io | ✅ OpenLinage.io |
|
||||
| Вьюхи | ✅ Вьюхи | ⚠️ Yuhi |
|
||||
|
||||
#### Русская разговорная речь и пунктуация
|
||||
|
||||
Качество сопоставимо с CT2 int8_float32. Пунктуация хорошая в обоих случаях.
|
||||
Сегменты у whisper.cpp короче (2-10 сек vs ~1 мин) — удобнее для навигации.
|
||||
|
||||
### Вывод
|
||||
|
||||
whisper.cpp + Vulkan на Radeon 780M даёт **432с** — всего **28% быстрее** CT2 int8_float32
|
||||
(599с). Разница недостаточна, чтобы оправдать сложность интеграции:
|
||||
|
||||
- Отдельная сборка whisper.cpp (cmake + MinGW + VulkanSDK)
|
||||
- Другой формат моделей (GGML вместо CTranslate2)
|
||||
- Workaround `-mc 0` от зацикливания
|
||||
- Конвертация в WAV (whisper.cpp не принимает mp4 напрямую)
|
||||
- Ещё одна зависимость для пользователя
|
||||
|
||||
**CT2 int8_float32 остаётся оптимальным выбором**: уже встроен в проект, zero-config,
|
||||
хорошее качество, приемлемая скорость.
|
||||
|
||||
## Рекомендуемый план (обновлённый)
|
||||
|
||||
1. ~~**Прототип в WSL** — собрать whisper.cpp с Vulkan, прогнать тестовый файл~~ ✅ Сделано
|
||||
2. ~~**Качество подтверждено** — whisper.cpp medium f16 ≈ CT2 float32/int8_float32~~ ✅
|
||||
3. ~~**GPU-ускорение в WSL2** — Vulkan не видит AMD GPU~~ ✅ Подтверждено
|
||||
4. ~~**Windows-сборка с Vulkan** — 432с, 2x vs CPU, но всего 28% vs CT2 int8_float32~~ ✅
|
||||
5. **Решение**: whisper.cpp + Vulkan не оправдывает себя на Radeon 780M iGPU.
|
||||
CT2 int8_float32 — оптимальный дефолт для CPU без CUDA.
|
||||
6. NPU — отложить до XDNA 2 или до появления pip-пакета от AMD
|
||||
7. Пересмотреть при появлении дискретного AMD GPU или WinML с Python-поддержкой
|
||||
@@ -0,0 +1,255 @@
|
||||
# Облегчённые модели диаризации: Pyannote INT8 и TitaNet small
|
||||
|
||||
**Дата:** 2026-08-29
|
||||
|
||||
**Статус:** исследовательская записка по публичным источникам. Выводы позднее
|
||||
проверены локальным CPU-стендом; рабочая конфигурация не менялась.
|
||||
|
||||
**Метод:** использованы только первичные публичные источники: документация,
|
||||
исходный код и выпуски sherpa-onnx, карточки владельцев моделей и исходная
|
||||
публикация TitaNet. Приватные медиа и опорные транскрипты Hypescribe не
|
||||
открывались.
|
||||
|
||||
## Краткий вывод
|
||||
|
||||
Sherpa-onnx официально документирует все четыре сочетания Pyannote
|
||||
Segmentation 3.0 FP32/INT8 с 3D-Speaker и `nemo_en_titanet_small.onnx`. В
|
||||
опубликованном разработчиками примере производительности переход FP32 → INT8
|
||||
сокращает
|
||||
RTF с 0,297 до 0,241 с 3D-Speaker и с 0,119 до 0,110 с TitaNet small. Но это
|
||||
одна 56,861-секундная китайская контрольная запись, известные четыре говорящих,
|
||||
CPU-провайдер и по одному потоку на сегментацию и эмбеддинги. Данных о
|
||||
конкретном CPU, повторностях, разбросе и качестве на русском материале страница
|
||||
не приводит. Поэтому цифры подтверждают совместимость и потенциал, но не
|
||||
заменяют локальный тест. Условия и результаты взяты с [официальной
|
||||
страницы sherpa-onnx](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/models.html).
|
||||
|
||||
Официальные файлы выпусков доступны под следующими именами:
|
||||
|
||||
- `sherpa-onnx-pyannote-segmentation-3-0.tar.bz2` содержит
|
||||
`model.onnx` (FP32) и `model.int8.onnx`;
|
||||
- `nemo_en_titanet_small.onnx` поставляется отдельным ONNX-файлом.
|
||||
|
||||
GitHub API не публикует `digest` ни для одного из этих файлов. Ниже зафиксированы
|
||||
SHA-256 байтов, скачанных 2026-08-29 с официальных URL, и точные размеры,
|
||||
совпавшие с GitHub API. Эти хеши пригодны для закрепления текущей поставки, но
|
||||
получены в этой проверке и не заявлены разработчиками как контрольные суммы.
|
||||
|
||||
## Официальная поддержка и точные артефакты
|
||||
|
||||
### Pyannote Segmentation 3.0: FP32 и INT8 в одном архиве
|
||||
|
||||
Официальная документация sherpa-onnx называет модель
|
||||
`sherpa-onnx-pyannote-segmentation-3-0`, указывает, что она конвертирована из
|
||||
[`pyannote/segmentation-3.0`](https://huggingface.co/pyannote/segmentation-3.0),
|
||||
ссылается на [официальный каталог скриптов
|
||||
конвертации](https://github.com/k2-fsa/sherpa-onnx/tree/master/scripts/pyannote/segmentation)
|
||||
и показывает оба файла, `model.onnx` и `model.int8.onnx`, в одном архиве
|
||||
([документация и команда загрузки](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/models.html#download-the-model)).
|
||||
|
||||
Точное имя и URL:
|
||||
|
||||
```text
|
||||
sherpa-onnx-pyannote-segmentation-3-0.tar.bz2
|
||||
https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-segmentation-models/sherpa-onnx-pyannote-segmentation-3-0.tar.bz2
|
||||
```
|
||||
|
||||
На момент проверки [GitHub Releases
|
||||
API](https://api.github.com/repos/k2-fsa/sherpa-onnx/releases/tags/speaker-segmentation-models)
|
||||
сообщает размер файла `6 958 444` байта,
|
||||
`created_at=2024-10-08T12:54:09Z`, `updated_at=2024-10-08T12:54:10Z` и
|
||||
`digest=null`; сам [официальный выпуск](https://github.com/k2-fsa/sherpa-onnx/releases/tag/speaker-segmentation-models)
|
||||
содержит этот файл, а документация даёт тот же URL загрузки.
|
||||
|
||||
Проверенное содержимое архива:
|
||||
|
||||
| Файл | Размер | SHA-256 |
|
||||
|---|---:|---|
|
||||
| `model.onnx` | 5 992 913 байт | `220ad67ca923bef2fa91f2390c786097bf305bceb5e261d4af67b38e938e1079` |
|
||||
| `model.int8.onnx` | 1 540 506 байт | `d582f4b4c6b48205de7e0643c57df0df5615a3c176189be3fc461e9d18827b5d` |
|
||||
|
||||
Архив также содержит `LICENSE`, `README.md` и скрипты экспорта/проверки. Это
|
||||
согласуется с опубликованным sherpa-onnx листингом: примерно 5,7 МБ для FP32 и
|
||||
1,5 МБ для INT8
|
||||
([официальный листинг](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/models.html#download-the-model)).
|
||||
|
||||
### NeMo TitaNet small: отдельная модель эмбеддингов
|
||||
|
||||
Официальная страница примеров загружает `nemo_en_titanet_small.onnx` из выпуска
|
||||
моделей распознавания говорящих и использует его как `--embedding.model` вместе с
|
||||
обоими вариантами Pyannote
|
||||
([FP32](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/models.html#nemo-model-onnx),
|
||||
[INT8](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/models.html#nemo-model-int8-onnx)).
|
||||
Отдельная [страница моделей NeMo в sherpa-onnx](https://k2-fsa.github.io/sherpa/onnx/nemo/index.html)
|
||||
перечисляет это точное имя среди поддерживаемых моделей эмбеддингов говорящих.
|
||||
|
||||
Точное имя и URL:
|
||||
|
||||
```text
|
||||
nemo_en_titanet_small.onnx
|
||||
https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/nemo_en_titanet_small.onnx
|
||||
```
|
||||
|
||||
Опечатка `recongition` является частью официальной метки выпуска и URL; исправлять
|
||||
её на `recognition` нельзя. На момент проверки [GitHub Releases
|
||||
API](https://api.github.com/repos/k2-fsa/sherpa-onnx/releases/tags/speaker-recongition-models)
|
||||
сообщает размер файла
|
||||
`40 257 283` байта, `created_at=2024-10-14T07:03:02Z`,
|
||||
`updated_at=2024-10-14T07:03:03Z` и `digest=null`; [выпуск
|
||||
sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx/releases/tag/speaker-recongition-models)
|
||||
предупреждает, что лицензия у каждой модели своя и её нужно искать в исходном
|
||||
репозитории модели.
|
||||
|
||||
## Контекст опубликованных результатов
|
||||
|
||||
### Пример производительности sherpa-onnx
|
||||
|
||||
Все четыре числа ниже опубликованы в одном примере sherpa-onnx. В нём используется
|
||||
`0-four-speakers-zh.wav` длительностью 56,861 с, явно задано
|
||||
`--clustering.num-clusters=4`, а распечатанная конфигурация показывает CPU
|
||||
провайдер и `num_threads=1` отдельно для сегментации и эмбеддингов. Страница не
|
||||
указывает модель CPU, не описывает прогрев, число повторов или дисперсию и не
|
||||
публикует метрику качества против эталонной разметки
|
||||
([полный пример](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/models.html#sherpa-onnx-pyannote-segmentation-3-0)).
|
||||
|
||||
| Segmentation | Embedding | Время | RTF |
|
||||
|---|---|---:|---:|
|
||||
| Pyannote FP32 `model.onnx` | 3D-Speaker ERes2Net base | 16,870 с | 0,297 |
|
||||
| Pyannote INT8 `model.int8.onnx` | 3D-Speaker ERes2Net base | 13,679 с | 0,241 |
|
||||
| Pyannote FP32 `model.onnx` | NeMo TitaNet small | 6,756 с | 0,119 |
|
||||
| Pyannote INT8 `model.int8.onnx` | NeMo TitaNet small | 6,231 с | 0,110 |
|
||||
|
||||
По опубликованным RTF мы пересчитали относительное ускорение. INT8 ускоряет
|
||||
сочетание с 3D-Speaker примерно на 18,9%, а сочетание с TitaNet small —
|
||||
примерно на 7,6%. Замена 3D-Speaker на TitaNet small при сохранении FP32 даёт
|
||||
около 59,9%, а сочетание TitaNet small + INT8 относительно исходного FP32 +
|
||||
3D-Speaker — около 63,0%. Здесь сравнивается только время выполнения на
|
||||
указанной записи. По этим цифрам нельзя судить о DER, переносимости ускорения
|
||||
на другой CPU или качестве на русском языке
|
||||
([исходные времена и RTF](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/models.html#sherpa-onnx-pyannote-segmentation-3-0)).
|
||||
|
||||
### Что означает «small» в публикации TitaNet
|
||||
|
||||
Исходная статья определяет TitaNet-S как вариант с 256 каналами и 6,4 млн
|
||||
параметров. Для очищенного набора VoxCeleb1 в задаче верификации говорящих она
|
||||
сообщает EER 1,15% при сравнении по косинусному сходству. Для диаризации авторы
|
||||
используют эталонный детектор голосовой активности (oracle SAD), кластеризацию
|
||||
NME-SC, допуск 0,25 с и исключают перекрытия из подсчёта DER. При
|
||||
известном числе говорящих DER TitaNet-S равен 6,37 / 2,00 / 2,22 / 1,11% на
|
||||
NIST-SRE-2000 / AMI-Lapel / AMI-MixHeadset / CH109; при оценённом числе —
|
||||
5,49 / 2,30 / 1,97 / 1,42%. Это тест архитектуры и исходной контрольной точки
|
||||
NeMo в другом конвейере диаризации, не тест ONNX-экспорта внутри
|
||||
sherpa-onnx
|
||||
([первичная публикация TitaNet](https://arxiv.org/abs/2110.04410)).
|
||||
|
||||
Карточка NVIDIA NGC подтверждает назначение `titanet_small` для верификации
|
||||
говорящих, диаризации и извлечения 192-мерных эмбеддингов, но её
|
||||
раздел Performance содержит внутренне противоречивую фразу «TitaNet-L» рядом с
|
||||
6,4 млн параметров. Поэтому для чисел TitaNet-S выше использована исходная
|
||||
статья вместо этой фразы карточки
|
||||
([официальная карточка NGC](https://catalog.ngc.nvidia.com/orgs/nvidia/teams/nemo/models/titanet_small)).
|
||||
|
||||
## Происхождение моделей и лицензии
|
||||
|
||||
### Pyannote Segmentation 3.0
|
||||
|
||||
Исходная карточка Pyannote описывает 10-секундный mono-вход 16 кГц и powerset
|
||||
выход из семи классов: тишина, три одиночных говорящих и три пары перекрывающихся
|
||||
говорящих. Модель обучил Séverin Baroudi в `pyannote.audio` 3.0.0 на сочетании
|
||||
AISHELL, AliMeeting, AMI, AVA-AVD, DIHARD, Ego4D, MSDWild, REPERE и VoxConverse
|
||||
([карточка исходной модели](https://huggingface.co/pyannote/segmentation-3.0)).
|
||||
|
||||
Исходный репозиторий помечает модель лицензией MIT и требует принять условия
|
||||
доступа к его файлам на Hugging Face. Публичный архив sherpa-onnx включает
|
||||
собственный `LICENSE` с текстом MIT; в проверенном архиве copyright указан как
|
||||
`Copyright (c) 2022 CNRS`, тогда как текущий LICENSE исходного репозитория
|
||||
указывает 2023. Тип лицензии совпадает, но при дальнейшей поставке следует
|
||||
сохранять именно `LICENSE` из закреплённого архива
|
||||
([лицензия и условия исходной модели](https://huggingface.co/pyannote/segmentation-3.0),
|
||||
[текущий LICENSE upstream](https://huggingface.co/pyannote/segmentation-3.0/blob/main/LICENSE)).
|
||||
|
||||
### NeMo TitaNet small
|
||||
|
||||
Sherpa-onnx публикует [скрипт экспорта](https://github.com/k2-fsa/sherpa-onnx/blob/master/scripts/nemo/speaker-verification/export-onnx.py),
|
||||
который загружает `EncDecSpeakerLabelModel.from_pretrained(model_name="titanet_small")`,
|
||||
экспортирует `nemo_en_titanet_small.onnx` и добавляет ONNX metadata с framework
|
||||
`nemo`, language `English`, source URL NGC, sample rate и размерностью embedding.
|
||||
[README каталога экспорта](https://github.com/k2-fsa/sherpa-onnx/tree/master/scripts/nemo/speaker-verification)
|
||||
также прямо называет NGC `titanet_small` источником модели.
|
||||
|
||||
Карточка NGC называет актуальную опубликованную версию `1.19.0`, обновлённую
|
||||
7 июня 2023 года, и описывает обучение на VoxCeleb 1/2, RIR noise, Fisher,
|
||||
Switchboard и LibriSpeech. Реализация NeMo связывает имя `titanet_small` с
|
||||
checkpoint `titanet-s.nemo` версии `1.19.0`
|
||||
([карточка NVIDIA NGC](https://catalog.ngc.nvidia.com/orgs/nvidia/teams/nemo/models/titanet_small),
|
||||
[официальный реестр pretrained-моделей NeMo](https://github.com/NVIDIA-NeMo/Speech/blob/main/nemo/collections/asr/models/label_models.py)).
|
||||
|
||||
NGC говорит, что использование модели регулируется лицензией NeMo; ссылка из
|
||||
карточки ведёт на Apache License 2.0. Сам файл выпуска sherpa-onnx — одиночный
|
||||
ONNX-файл без соседнего LICENSE, а описание выпуска перекладывает проверку
|
||||
лицензии на пользователя. Поэтому при включении модели в приложение нужно
|
||||
зафиксировать происхождение NVIDIA NeMo/TitaNet-S и приложить лицензию Apache-2.0,
|
||||
а не считать общий LICENSE sherpa-onnx лицензией весов
|
||||
([секция Licence карточки NGC](https://catalog.ngc.nvidia.com/orgs/nvidia/teams/nemo/models/titanet_small),
|
||||
[LICENSE NeMo](https://github.com/NVIDIA-NeMo/Speech/blob/main/LICENSE),
|
||||
[предупреждение release sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx/releases/tag/speaker-recongition-models)).
|
||||
|
||||
Происхождение нельзя воспроизвести полностью: текущий `run.sh` sherpa-onnx
|
||||
устанавливает NeMo из ветки `main` без закрепления коммита или метки, затем
|
||||
вызывает экспорт
|
||||
для `titanet_small`. Он документирует процесс, но сам по себе не доказывает,
|
||||
из какого коммита NeMo был создан файл от 2024-10-14. Для побайтовой
|
||||
воспроизводимости следует считать официально скачанный ONNX плюс SHA-256 ниже
|
||||
канонической единицей, пока разработчики не опубликуют данные закреплённой сборки
|
||||
([официальный `run.sh`](https://github.com/k2-fsa/sherpa-onnx/blob/master/scripts/nemo/speaker-verification/run.sh)).
|
||||
|
||||
## Проверенные SHA-256
|
||||
|
||||
Файлы скачаны 2026-08-29 напрямую с двух URL выше в `/tmp`; размер каждого
|
||||
сверен с полем `size` GitHub Releases API. Архив дополнительно проверен командой
|
||||
`bzip2 -t`. Поле `digest` в API было `null` для обоих файлов, поэтому ни один из
|
||||
следующих хешей нельзя называть опубликованной контрольной суммой.
|
||||
|
||||
```text
|
||||
24615ee884c897d9d2ba09bb4d30da6bb1b15e685065962db5b02e76e4996488 sherpa-onnx-pyannote-segmentation-3-0.tar.bz2
|
||||
ad4a1802485d8b34c722d2a9d04249662f2ece5d28a7a039063ca22f515a789e nemo_en_titanet_small.onnx
|
||||
```
|
||||
|
||||
Внутри проверенного Pyannote-архива:
|
||||
|
||||
```text
|
||||
220ad67ca923bef2fa91f2390c786097bf305bceb5e261d4af67b38e938e1079 model.onnx
|
||||
d582f4b4c6b48205de7e0643c57df0df5615a3c176189be3fc461e9d18827b5d model.int8.onnx
|
||||
```
|
||||
|
||||
Команда проверки после загрузки:
|
||||
|
||||
```bash
|
||||
sha256sum \
|
||||
sherpa-onnx-pyannote-segmentation-3-0.tar.bz2 \
|
||||
nemo_en_titanet_small.onnx
|
||||
```
|
||||
|
||||
## Результат локальной проверки
|
||||
|
||||
[Прогон на Core i7-6820HQ](../benchmarks/2026-08-30-lightweight-diarization-cpu.md)
|
||||
не подтвердил опубликованное ускорение INT8: сочетание с WeSpeaker выиграло
|
||||
только 2,1%, а с TitaNet стало на 5,7% медленнее. TitaNet ускорил обработку в
|
||||
среднем на 24,3%, но потребовал примерно на 147–149 МиБ больше RSS и не прошёл
|
||||
приёмку общего автоматического порога.
|
||||
|
||||
## Практический вердикт
|
||||
|
||||
1. Для минимального эксперимента можно заменить только путь Pyannote
|
||||
`model.onnx` на `model.int8.onnx`: это официально поставляемый вариант того же
|
||||
архива и не требует менять модель эмбеддингов
|
||||
([официальный пример INT8](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/models.html#d-speaker-model-int8-onnx)).
|
||||
2. Для большего потенциального ускорения sherpa-onnx официально принимает
|
||||
`nemo_en_titanet_small.onnx` с обоими вариантами Pyannote, но опубликованный
|
||||
пример производительности не измеряет DER и не относится к русской речи
|
||||
([официальные примеры NeMo](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/models.html#nemo-model-onnx)).
|
||||
3. Для воспроизводимой поставки следует закрепить точные URL, размеры и SHA-256,
|
||||
хранить MIT LICENSE из Pyannote-архива и явно приложить сведения об авторстве
|
||||
и лицензии Apache-2.0 для TitaNet/NeMo. До локальной калибровки нельзя
|
||||
переносить ни коэффициент ускорения, ни порог кластеризации, ни качество с
|
||||
опубликованной контрольной записи на целевые русскоязычные созвоны.
|
||||
@@ -0,0 +1,221 @@
|
||||
# Ускорение извлечения голосовых эмбеддингов на x86 CPU
|
||||
|
||||
**Дата:** 2026-08-30
|
||||
|
||||
**Статус:** исследовательская записка. Production-код и конфигурация не
|
||||
менялись.
|
||||
|
||||
**Метод:** проверены `sherpa-onnx==1.13.5`, `onnxruntime==1.28.0`, официальные
|
||||
скрипты экспорта и исходники владельцев моделей. Три официальных release asset
|
||||
прочитаны потоком без сохранения на диск: WeSpeaker ResNet34 LM, CAMPPlus en и
|
||||
TitaNet small. Их входы, выходы и метаданные разобраны через ORT 1.28.0; для
|
||||
каждого выполнен CPU smoke-test с batch size 2.
|
||||
|
||||
## Вопрос и краткий ответ
|
||||
|
||||
Текущий путь не использует batch: sherpa-onnx последовательно создает stream и
|
||||
вызывает один `Session::Run` для каждой пары "окно, локальный говорящий".
|
||||
Публичный `SpeakerEmbeddingExtractor` также принимает один stream за вызов.
|
||||
|
||||
Из трех направлений достойны прототипа два:
|
||||
|
||||
1. статическая INT8-квантизация действующего WeSpeaker в формате S8S8 QDQ;
|
||||
2. несколько независимых stream поверх **одной** ORT session с ограниченным
|
||||
общим числом потоков.
|
||||
|
||||
Настоящий batch технически лучше всего подготовлен у TitaNet: граф принимает
|
||||
вектор длин. Но TitaNet пока допустим только при явно известном числе говорящих,
|
||||
а sherpa-onnx все равно фиксирует batch size 1. Поэтому batch TitaNet -
|
||||
условный следующий прототип; действующую конфигурацию он не ускоряет.
|
||||
|
||||
Несколько отдельных session не стоит прототипировать первым: это не уменьшает
|
||||
работу, создает отдельные пулы потоков и арены памяти, повышает риск
|
||||
oversubscription и
|
||||
роста RSS. Для WeSpeaker и CAMPPlus batch с простым дополнением также не готов:
|
||||
их графы не принимают длины или маску.
|
||||
|
||||
## Что именно выполняется последовательно
|
||||
|
||||
Приложение создает один `OfflineSpeakerDiarization` и задает одно и то же
|
||||
`num_threads` отдельно для segmentation и embedding
|
||||
([адаптер проекта](../../src/local_transcriber/speaker_diarizer.py)). Внутри
|
||||
sherpa-onnx стадии идут строго `segmentation → embeddings → clustering`, а
|
||||
`ComputeEmbeddings` проходит список пар обычным циклом: создает stream,
|
||||
добавляет все неперекрывающиеся фрагменты одного локального говорящего,
|
||||
завершает input и синхронно вызывает `Compute`
|
||||
([реализация Pyannote в sherpa-onnx 1.13.5](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/offline-speaker-diarization-pyannote-impl.h)).
|
||||
|
||||
Публичный C++ и Python API экспортирует `create_stream`, `is_ready` и
|
||||
`compute(stream)`. Метода `compute_batch(streams)` нет
|
||||
([C++ API](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/speaker-embedding-extractor.h),
|
||||
[Python binding](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/python/csrc/speaker-embedding-extractor.cc)).
|
||||
Python binding освобождает GIL на `compute`, поэтому внешний параллельный вызов
|
||||
в принципе возможен; готовый диаризатор промежуточные stream наружу не отдает.
|
||||
|
||||
Для embedding создается одна ORT session. Sherpa-onnx передает заданное число и
|
||||
в `intra_op_num_threads`, и в `inter_op_num_threads`
|
||||
([настройка session](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/session.cc)).
|
||||
ORT по умолчанию исполняет граф последовательно; `inter_op` начинает
|
||||
распараллеливать узлы только при `ORT_PARALLEL`, тогда как `intra_op`
|
||||
распараллеливает оператор
|
||||
([официальная документация ORT](https://onnxruntime.ai/docs/performance/tune-performance/threading.html)).
|
||||
Значит, в текущем CPU-пути основная ручка - `intra_op`.
|
||||
|
||||
## Контракты трех графов
|
||||
|
||||
| Модель | Вход и выход официального экспорта | Следствие для batch |
|
||||
|---|---|---|
|
||||
| WeSpeaker ResNet34 LM | Release asset имеет один `float32`-вход `[B, T, 80]` и выход `[B, 256]` ([asset](https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/wespeaker_en_voxceleb_resnet34_LM.onnx)). Скрипт, которым sherpa-onnx добавляет метаданные, проверяет символические `B`, `T`, размерность 80 и единственный выход. Метаданные включают `framework=wespeaker`, `sample_rate`, `output_dim`, `normalize_samples=0` ([проверка sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/scripts/wespeaker/add_meta_data.py), [экспорт WeSpeaker](https://github.com/wenet-e2e/wespeaker/blob/master/wespeaker/bin/export_onnx.py)). | Равные `T` можно сложить по `B`. Длины или маску граф не принимает; дополнение участвует в pooling и меняет эмбеддинг. |
|
||||
| CAMPPlus en | Release asset имеет `float32`-вход `[N, T, 80]` и выход с динамической первой осью `[*, 512]` ([asset](https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/3dspeaker_speech_campplus_sv_en_voxceleb_16k.onnx)). Метаданные: `framework=3d-speaker`, `output_dim=512`, `normalize_samples=1`, `feature_normalize_type=global-mean` ([экспорт sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/scripts/3dspeaker/export-onnx.py)). | Несмотря на опечатку экспортного скрипта (`embedding`/`embeddings`), release asset принимает batch равной длины и возвращает все строки. Для разных длин граф по-прежнему не принимает маску или длины. |
|
||||
| NeMo TitaNet small | Release asset имеет входы `float32 [N, 80, T]` и `int64 [N]`; выходы — logits `[N, 16681]` и embedding `[N, 192]` ([asset](https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/nemo_en_titanet_small.onnx), [контракт C++-обертки](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/speaker-embedding-extractor-nemo-model.h)). Метаданные содержат `framework=nemo`, `output_dim`, `feat_dim`, sample rate, параметры окна и нормализации ([экспорт](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/scripts/nemo/speaker-verification/export-onnx.py)). | Граф принимает batch и вектор длин. Но sherpa-onnx создает обе входные формы с `N=1` и возвращает только первую строку ([реализация](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/speaker-embedding-extractor-nemo-impl.h)). |
|
||||
|
||||
Smoke-test с синтетическими `float32`-признаками и одним intra-op thread дал
|
||||
для WeSpeaker `(2, 256)`, для CAMPPlus `(2, 512)` и для TitaNet `(2, 192)`.
|
||||
У WeSpeaker и CAMPPlus результат batch size 2 совпал с конкатенацией двух
|
||||
последовательных `Run` (`max_abs_diff=0`). У TitaNet элемент длиной 100 кадров
|
||||
также совпал, но элемент длиной 80, дополненный до 100 кадров, отличался от
|
||||
отдельного непаддированного вызова (`max_abs_diff=0,0269`). Это не тест качества
|
||||
на голосе, а подтверждение, что допустимый порог для variable-length batch нужно
|
||||
определить в прототипе, а не считать эквивалентность гарантированной контрактом.
|
||||
|
||||
WeSpeaker ResNet34 состоит из Conv2d-блоков и statistics pooling
|
||||
([исходник WeSpeaker](https://github.com/wenet-e2e/wespeaker/blob/master/wespeaker/models/resnet.py));
|
||||
CAMPPlus сочетает Conv2d, TDNN/Conv1d и statistics pooling
|
||||
([исходник 3D-Speaker](https://github.com/modelscope/3D-Speaker/blob/main/speakerlab/models/campplus/DTDNN.py));
|
||||
TitaNet использует depth-wise separable Conv1d, squeeze-and-excitation и
|
||||
attention statistics pooling
|
||||
([документация NeMo](https://docs.nvidia.com/nemo-framework/user-guide/25.02/nemotoolkit/asr/speaker_recognition/models.html)).
|
||||
Этого недостаточно, чтобы утверждать: каждый оператор будет квантизован и
|
||||
ускорен.
|
||||
|
||||
В [официальном выпуске speaker-recognition models](https://github.com/k2-fsa/sherpa-onnx/releases/tag/speaker-recongition-models)
|
||||
есть только FP32-файлы WeSpeaker, CAMPPlus и TitaNet; готовых INT8 embedding
|
||||
assets нет. Статический INT8 здесь означает собственный производный артефакт с
|
||||
отдельной проверкой лицензии, метаданных и качества.
|
||||
|
||||
## Оценка способов ускорения
|
||||
|
||||
| Способ | Техническая доступность | Вердикт |
|
||||
|---|---|---|
|
||||
| Настоящий batch | Release-графы WeSpeaker и CAMPPlus поддерживают batch равной длины; TitaNet принимает batch и второй вход с длинами. Sherpa-onnx для всех трех жестко формирует `N=1` и не имеет batch API. | Прототипировать только TitaNet и только условно для режима с известным числом говорящих. Для WeSpeaker/CAMPPlus без маски возможны лишь группы с одинаковым `T`; ожидаемая наполняемость неизвестна. |
|
||||
| Несколько stream, одна session | Stream независимы, `Compute` использует локальные тензоры, а ORT разрешает нескольким потокам одновременно вызывать `Run` одной session ([архитектура ORT](https://onnxruntime.ai/docs/reference/high-level-design.html), [контракт 1.28.0](https://github.com/microsoft/onnxruntime/blob/v1.28.0/onnxruntime/core/session/inference_session.h)). Текущий цикл sherpa-onnx этого не делает. | Достойно узкого прототипа на WeSpeaker. У одной session общий intra-op pool; нужно сравнить внутренний и внешний параллелизм. Полный pool для каждого worker не нужен. |
|
||||
| Несколько session | Можно создать несколько extractor, но по умолчанию каждая session получает собственный intra-op pool и CPU arena. ORT отдельно предлагает общий pool против конкуренции session pools и общий allocator против роста памяти ([thread management](https://onnxruntime.ai/docs/performance/tune-performance/threading.html), [C API guide](https://onnxruntime.ai/docs/get-started/with-c.html#share-allocator-s-between-sessions)). | Не брать как самостоятельный кандидат. Допустима одна контрольная ячейка против shared-session, чтобы подтвердить отказ по реальному времени и RSS. |
|
||||
| Статический INT8 | ORT предоставляет `quantize_static`, использует calibration data и рекомендует static quantization для CNN. Для CPU первым выбором служит S8S8 QDQ; квантизация может ухудшить точность и даже скорость ([официальное руководство](https://onnxruntime.ai/docs/performance/model-optimizations/quantization.html)). | Главный кандидат для действующего WeSpeaker. CAMPPlus и TitaNet квантизовать только после продуктового решения использовать соответствующую модель. |
|
||||
|
||||
Batch сокращает число `Session::Run` и может лучше загрузить SIMD, но число
|
||||
эмбеддингов и основная модельная арифметика сохраняются; padding может даже
|
||||
добавить работу. Несколько stream или session лишь перекрывают независимые
|
||||
вызовы. Только INT8 меняет арифметику и объем движения весов. Увеличение шага
|
||||
окна или отказ от части эмбеддингов действительно сокращают работу. Их нужно
|
||||
рассматривать в отдельном исследовании шага окна.
|
||||
|
||||
## ISA, oversubscription и память
|
||||
|
||||
AVX2, VNNI и AVX-512 служат уровнями runtime dispatch одного формата модели.
|
||||
MLAS в ORT 1.28.0 проверяет CPUID во время запуска и выбирает отдельные AVX2,
|
||||
AVX-VNNI, AVX-512 и
|
||||
AVX512-VNNI kernels
|
||||
([runtime dispatch](https://github.com/microsoft/onnxruntime/blob/v1.28.0/onnxruntime/core/mlas/lib/platform.cpp)).
|
||||
Один QDQ-граф поэтому нужно измерять на разных CPU. Отдельная сборка под каждый
|
||||
из них не нужна.
|
||||
|
||||
Официальное руководство ORT предупреждает:
|
||||
|
||||
- S8S8 QDQ остается первым выбором CPU. Если он дает значимую потерю
|
||||
точности, ORT советует проверить U8U8;
|
||||
- отдельный путь U8S8 на AVX2 и AVX-512 без VNNI использует `VPMADDUBSW` и
|
||||
может дать saturation; если до него дойдет матрица, этот риск снижают
|
||||
`reduce_range` или переход на U8U8;
|
||||
- на x86 с VNNI этой проблемы нет, а INT8 обычно выигрывает больше;
|
||||
- на старом CPU без подходящих инструкций quantize/dequantize overhead способен
|
||||
сделать модель медленнее
|
||||
([разделы Data type selection и FAQ](https://onnxruntime.ai/docs/performance/model-optimizations/quantization.html)).
|
||||
|
||||
Доступный Core i7-6820HQ официально заявляет только SSE4.1/4.2 и AVX2
|
||||
([Intel ARK](https://www.intel.com/content/www/us/en/products/sku/88970/intel-core-i76820hq-processor-8m-cache-up-to-3-60-ghz/specifications.html)).
|
||||
Современный класс должен выбираться по фактическим CPUID-флагам. Надписи
|
||||
"11-е поколение" недостаточно: минимум нужен `avx512_vnni` или `avx_vnni`. Для доступного
|
||||
Zen 4 AMD документирует AVX-512 и AVX512_VNNI как путь ускоренного integer GEMM
|
||||
([AMD AOCL](https://docs.amd.com/r/en-US/68552-AOCL-api-guide/Hardware-Utilization)).
|
||||
|
||||
В параллельном прототипе нужно измерять RSS. Уже последовательный тест показал,
|
||||
что TitaNet требует примерно на 147–149 МиБ больше RSS, чем WeSpeaker, а FP32
|
||||
WeSpeaker на старом Intel использовал в среднем 6,84 логического ядра из восьми
|
||||
([локальный benchmark](../benchmarks/2026-08-30-lightweight-diarization-cpu.md)).
|
||||
Конкурентные `Run` одной session делят общий intra-op pool, но все равно
|
||||
конкурируют за его очереди и добавляют вызывающие потоки. Несколько session уже
|
||||
создают отдельные пулы и дополнительно размножают арены и подготовленные веса.
|
||||
Точный рост не следует считать линейным - его нужно измерить.
|
||||
|
||||
## Что остается неизвестным
|
||||
|
||||
- Реальный процент Conv/MatMul, который ORT сможет перевести в INT8, и выигрыш
|
||||
каждого графа на двух ISA-классах.
|
||||
- Сохранятся ли обязательные пользовательские метаданные после квантизации; без
|
||||
`framework`, `output_dim` и параметров обработки признаков sherpa-onnx модель
|
||||
не загрузит.
|
||||
- Точность статического INT8 после калибровки и необходимость исключить отдельные
|
||||
узлы через quantization debugging.
|
||||
- Распределение `T` у пар "окно, локальный говорящий". Без него нельзя оценить
|
||||
полезность exact-length buckets для WeSpeaker/CAMPPlus.
|
||||
- Выигрыш shared-session concurrency после ограничения общего числа потоков,
|
||||
его пиковый RSS и стабильность повторных конкурентных вызовов TitaNet.
|
||||
|
||||
## Рекомендация и минимальная граница прототипа
|
||||
|
||||
### 1. INT8 WeSpeaker - первый кандидат
|
||||
|
||||
На отдельном стенде, не меняя загрузчик проекта:
|
||||
|
||||
- выполнить `quant_pre_process`, затем `quantize_static` в S8S8 QDQ;
|
||||
- проверить opset, вход/выход, все пользовательские метаданные и загрузку через
|
||||
`SpeakerEmbeddingExtractor`;
|
||||
- сравнить FP32 и INT8 по реальному времени embedding-стадии, процессорному
|
||||
времени, пиковому RSS и
|
||||
расстоянию между эмбеддингами;
|
||||
- только после технического выигрыша повторить действующие проверки числа
|
||||
голосовых кластеров, purity и слов без говорящего.
|
||||
|
||||
При значительной потере точности S8S8 проверить U8U8. Отдельный U8S8 с
|
||||
`reduce_range` в первую матрицу не входит.
|
||||
|
||||
### 2. Shared-session streams - второй кандидат
|
||||
|
||||
Изолированный стенд или минимальная upstream-поправка должен сохранить одну
|
||||
модель и одну ORT session, подготовить независимые stream и сравнить:
|
||||
|
||||
```text
|
||||
outer workers / shared intra-op pool: 1 / P, 2 / P, P / 1
|
||||
inter-op threads: 1
|
||||
```
|
||||
|
||||
Здесь `P` - число физических ядер. Первая ячейка показывает текущий внутренний
|
||||
параллелизм, вторая проверяет конкурентные `Run` на общем pool, третья переносит
|
||||
параллелизм наружу и не создает intra-op pool. Измерять нужно полный набор
|
||||
независимых embedding-вызовов. Latency одного удачного stream для решения
|
||||
недостаточно. Отдельные session оставить одной отрицательной контрольной ячейкой
|
||||
`P workers / 1 thread`.
|
||||
|
||||
### 3. Batch TitaNet - условный кандидат
|
||||
|
||||
Если отдельное решение разрешит TitaNet для режима с известным числом
|
||||
говорящих, прямой ORT-стенд должен сначала доказать на размерах batch `1 / 4`,
|
||||
что batch переменной длины с дополнением и `x_lens` дает те же эмбеддинги в
|
||||
заданном допуске, что и последовательные вызовы. Затем измерить пропускную
|
||||
способность и RSS. До этого менять
|
||||
sherpa-onnx API ради TitaNet преждевременно. CAMPPlus и batch без mask в эту
|
||||
границу не входят.
|
||||
|
||||
Минимальная доступная CPU-матрица для обоих непосредственных кандидатов:
|
||||
|
||||
| Класс | Конкретный доступный ориентир | Зачем |
|
||||
|---|---|---|
|
||||
| Консервативный x86 baseline: AVX2, без VNNI/AVX-512 | Intel Core i7-6820HQ, 4C/8T | Проверить нижнюю границу INT8 и конкуренцию на старом CPU. |
|
||||
| Современный x86 baseline: VNNI + AVX-512 | AMD Ryzen 7 8845H из предыдущих замеров, с обязательной фиксацией `lscpu`/CPUID | Проверить специализированный INT8 kernel и масштабирование на 8C/16T. |
|
||||
|
||||
Эти две машины доступны для воспроизводимых прогонов; ни одна из них не является
|
||||
целевым SKU проекта. Ноутбук с Intel Core i5 11-го поколения - лишь пример
|
||||
распространенного пользовательского класса и не обязательная приемочная машина.
|
||||
Для первого отбора кандидата двух ориентиров достаточно; на любой дополнительной
|
||||
машине нужно записывать точную модель и CPUID. Переносить полученный коэффициент
|
||||
ускорения на весь x86 нельзя.
|
||||
@@ -0,0 +1,402 @@
|
||||
# Оценка числа говорящих для быстрых эмбеддингов
|
||||
|
||||
**Дата:** 2026-08-30
|
||||
|
||||
**Статус:** исследовательская записка. Production-код и конфигурация не
|
||||
менялись.
|
||||
|
||||
## Вопрос
|
||||
|
||||
Как отделить оценку числа говорящих от порога `FastClustering`, чтобы
|
||||
использовать быстрые CAMPPlus или TitaNet и не запускать сегментацию и
|
||||
извлечение эмбеддингов второй раз?
|
||||
|
||||
## Краткий вывод
|
||||
|
||||
Нужна двухшаговая схема: один раз получить ровно те эмбеддинги, которые
|
||||
подготовил конвейер sherpa-onnx, отдельно оценить по ним число голосовых
|
||||
кластеров `N`, затем передать `N` штатному `FastClustering`. Для первого
|
||||
прототипа рекомендован **счетчик NME**, без полной спектральной кластеризации:
|
||||
ядро алгоритма из статьи реализовать на NumPy, а итоговые метки по-прежнему
|
||||
получить через `FastClustering(num_clusters=N)`.
|
||||
Счетчик eigengap с фиксированным прореживанием нужен как дешевый контрольный
|
||||
вариант. Пока это гипотеза для прототипа, а не выбор production-алгоритма.
|
||||
|
||||
Публичного промежуточного интерфейса для этого в sherpa-onnx 1.13.5 нет.
|
||||
`SpeakerEmbeddingExtractor` и `FastClustering` публичны, но
|
||||
`OfflineSpeakerDiarization.process()` возвращает только готовые интервалы.
|
||||
Матрица эмбеддингов и соответствие строк локальным говорящим остаются внутри
|
||||
реализации. После `set_config()` повторный `process()` снова выполнит модельный
|
||||
инференс. Python-адаптер проекта сам по себе не может дать дешевую повторную
|
||||
кластеризацию; будущему прототипу нужен узкий исследовательский интерфейс на
|
||||
уровне sherpa-onnx.
|
||||
|
||||
## Что подтверждено про sherpa-onnx
|
||||
|
||||
### `FastClustering` уже умеет второй шаг
|
||||
|
||||
В версии 1.13.5 алгоритм нормирует строки, строит все попарные косинусные
|
||||
расстояния, выполняет AHC методом полной связи и затем выбирает один из двух
|
||||
способов разрезать одно и то же дерево:
|
||||
|
||||
- при `num_clusters > 0`: ровно на заданное число кластеров;
|
||||
- иначе: по высоте `threshold`.
|
||||
|
||||
Так работает
|
||||
[`fast-clustering.cc`](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/fast-clustering.cc)
|
||||
и закреплено в комментарии к
|
||||
[`FastClusteringConfig`](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/fast-clustering-config.h):
|
||||
при известном числе кластеров порог игнорируется. Python-модуль публично
|
||||
принимает C-contiguous матрицу `float32` размера `M x D` и возвращает метку для
|
||||
каждой строки
|
||||
([исходник Python-модуля](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/python/csrc/fast-clustering.cc)).
|
||||
Значит, передавать оцененное `N` в существующий кластеризатор не требует новой
|
||||
реализации распределения эмбеддингов по кластерам.
|
||||
|
||||
Порог `FastClustering` не принадлежит модели эмбеддингов: он задает высоту
|
||||
дерева полной связи в шкале косинусного расстояния. Официальный пример CAMPPlus
|
||||
для проверки говорящего лишь вычисляет косинусное сходство и не задает
|
||||
универсальной границы решения
|
||||
([`infer_sv.py`](https://github.com/modelscope/3D-Speaker/blob/065629c313eaf1a01c65c640c46d77e61e9607b4/speakerlab/bin/infer_sv.py)).
|
||||
Официальный рецепт диаризации 3D-Speaker отдельно задает `pval=0.012` для
|
||||
спектральной кластеризации и `mer_cos=0.8` для последующего слияния центров
|
||||
([`infer_diarization.py`](https://github.com/modelscope/3D-Speaker/blob/065629c313eaf1a01c65c640c46d77e61e9607b4/speakerlab/bin/infer_diarization.py)).
|
||||
Эти величины имеют другой смысл и не могут быть перенесены в sherpa как
|
||||
`порог модели`.
|
||||
|
||||
### Публичных деталей недостаточно для повтора всего конвейера
|
||||
|
||||
Публичный `SpeakerEmbeddingExtractor` позволяет создать поток, подать в него
|
||||
аудио и получить один эмбеддинг
|
||||
([Python-модуль](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/python/csrc/speaker-embedding-extractor.cc),
|
||||
[C/C++ API](https://k2-fsa.github.io/sherpa/onnx/c-api/html/speaker_embedding.html)).
|
||||
Интерфейс пригоден для самостоятельно выбранного фрагмента, но не выдает
|
||||
материал, который выбрал конвейер диаризации.
|
||||
|
||||
Внутри `OfflineSpeakerDiarization` после сегментации:
|
||||
|
||||
1. из перекрывающихся окон строятся пары `окно x локальный говорящий`;
|
||||
2. речь с одновременными голосами исключается;
|
||||
3. для каждой пары собирается свой поток и вычисляется эмбеддинг;
|
||||
4. вся матрица передается в `FastClustering`;
|
||||
5. метки строк переводятся обратно в разметку говорящих.
|
||||
|
||||
Последовательность и временная матрица видны в
|
||||
[`OfflineSpeakerDiarizationPyannoteImpl::Process`](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/offline-speaker-diarization-pyannote-impl.h#L87-L204),
|
||||
а сборка строк описана в
|
||||
[`GetChunkSpeakerSampleIndexes` и `ComputeEmbeddings`](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/offline-speaker-diarization-pyannote-impl.h#L376-L528).
|
||||
Оба метода закрыты внутри реализации.
|
||||
|
||||
Python-класс `OfflineSpeakerDiarization` экспортирует только `sample_rate`,
|
||||
`set_config` и `process`; `process` возвращает готовый результат, а не признаки
|
||||
([Python-модуль 1.13.5](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/python/csrc/offline-speaker-diarization.cc#L73-L128)).
|
||||
Внутренний `SetConfig` заменяет только объект кластеризации, тогда как каждый
|
||||
`Process` заново вызывает сегментацию и `ComputeEmbeddings`
|
||||
([реализация](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/offline-speaker-diarization-pyannote-impl.h#L79-L175)).
|
||||
Публичная C API также документирует только создание, настройку и получение
|
||||
итоговых сегментов
|
||||
([официальная страница](https://k2-fsa.github.io/sherpa/onnx/c-api/html/speaker_diarization.html)).
|
||||
|
||||
Итог: публичный API позволяет **кластеризовать имеющуюся матрицу повторно**.
|
||||
Извлечь или повторно обработать промежуточную матрицу штатного конвейера
|
||||
диаризации через него нельзя.
|
||||
|
||||
## Варианты оценки `N`
|
||||
|
||||
### 1. Отдельный порог для каждой модели эмбеддингов
|
||||
|
||||
Автоматический режим `FastClustering` дешев, не добавляет зависимостей и за один
|
||||
проход оценивает число кластеров и назначает метки. На порог влияют архитектура
|
||||
модели эмбеддингов, длительность и состав выбранных окон, акустика и
|
||||
распределение голосов. Локальная калибровка уже не нашла общего рабочего порога
|
||||
для CAMPPlus, а TitaNet не прошел автоматическую сетку
|
||||
([исходное исследование](2026-08-14-diarization-cpu-cost.md),
|
||||
[повторный CPU-тест](../benchmarks/2026-08-30-lightweight-diarization-cpu.md)).
|
||||
|
||||
Вводить еще один `официальный порог модели` оснований нет: исходные рецепты
|
||||
публикуют параметры конкретного конвейера кластеризации, а не переносимую
|
||||
границу для метода полной связи в sherpa. Вариант остается контрольным, но не
|
||||
решает тикет.
|
||||
|
||||
### 2. Счетчик eigengap
|
||||
|
||||
Официальный CAMPPlus-конвейер 3D-Speaker строит матрицу косинусного сходства.
|
||||
В каждой строке он оставляет фиксированную долю ближайших соседей,
|
||||
симметризует матрицу, затем вычисляет ненормированный лапласиан и несколько
|
||||
наименьших собственных пар.
|
||||
Число говорящих задает позиция максимального разрыва собственных значений
|
||||
(`eigengap`) в заданных пределах, после чего собственные векторы
|
||||
кластеризуются k-means
|
||||
([`SpectralCluster`](https://github.com/modelscope/3D-Speaker/blob/065629c313eaf1a01c65c640c46d77e61e9607b4/speakerlab/process/cluster.py#L21-L106)).
|
||||
|
||||
Для нашей двухшаговой схемы нужны только собственные значения и оценка `N`;
|
||||
k-means можно не выполнять. Одного спектрального разложения достаточно для
|
||||
минимального контрольного варианта, но фиксированный `pval` остается новым
|
||||
порогом. Официальная реализация задает его для своего способа нарезки; пары
|
||||
`окно x локальный говорящий` sherpa устроены иначе. Вариант отделяет `N` от
|
||||
порога AHC, но сохраняет калибруемый параметр.
|
||||
|
||||
Если брать реализацию 3D-Speaker как зависимость, она импортирует NumPy, SciPy,
|
||||
scikit-learn, fastcluster, UMAP и HDBSCAN, причем отсутствие UMAP или HDBSCAN
|
||||
роняет уже импорт модуля. Корневой `requirements.txt` при этом не перечисляет
|
||||
fastcluster, UMAP и HDBSCAN
|
||||
([исходник](https://github.com/modelscope/3D-Speaker/blob/065629c313eaf1a01c65c640c46d77e61e9607b4/speakerlab/process/cluster.py#L3-L18),
|
||||
[requirements](https://github.com/modelscope/3D-Speaker/blob/065629c313eaf1a01c65c640c46d77e61e9607b4/requirements.txt)).
|
||||
Для production это несоразмерно счетчику.
|
||||
|
||||
### 3. NME-SC
|
||||
|
||||
NME-SC создан именно для совместной оценки числа кластеров и параметра
|
||||
прореживания без отдельной выборки для настройки. Для каждого числа соседей
|
||||
`p` алгоритм бинаризует матрицу косинусного сходства, симметризует ее, строит
|
||||
ненормированный лапласиан и считает разрывы собственных значений. Нормированный
|
||||
разрыв равен максимальному разрыву, деленному на наибольшее собственное
|
||||
значение; выбирается `p`, минимизирующий отношение доли соседей `p/M` к
|
||||
нормированному разрыву, а `N` задает позиция максимального разрыва
|
||||
([статья авторов, раздел III](https://arxiv.org/abs/2003.02405)).
|
||||
Авторы ограничивали поиск `p` диапазоном до четверти числа строк, а число
|
||||
говорящих числом восемь. Результаты статьи получены на x-vector и англоязычных
|
||||
корпусах, поэтому перенос на наши эмбеддинги еще предстоит проверить.
|
||||
|
||||
Поддерживаемая реализация NeMo разделяет оценку и назначение меток. `NMESC`
|
||||
принимает готовую матрицу сходства и возвращает `est_num_of_spk` вместе с
|
||||
выбранным `p`; следующий слой отдельно выбирает оцененное или известное число
|
||||
говорящих перед спектральной кластеризацией
|
||||
([`NMESC` и выбор числа кластеров](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/nemo/collections/asr/parts/utils/offline_clustering.py#L909-L1041),
|
||||
[`SpeakerClustering.forward_unit_infer`](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/nemo/collections/asr/parts/utils/offline_clustering.py#L1181-L1259)).
|
||||
Более высокий интерфейс NeMo получает многомасштабные эмбеддинги вместе с
|
||||
временной разметкой и сам строит матрицу сходства
|
||||
([`forward_infer`](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/nemo/collections/asr/parts/utils/offline_clustering.py#L1301-L1403)).
|
||||
|
||||
Для ускорения NME NeMo задает целевой размер матрицы 512 строк, перебирает
|
||||
разреженную сетку из 30 значений `p` и предупреждает, что меньше 20 может
|
||||
ухудшить оценку
|
||||
([класс `NMESC`](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/nemo/collections/asr/parts/utils/offline_clustering.py#L872-L977)).
|
||||
Предел не жесткий: регулярный шаг вычисляется целочисленно, поэтому для
|
||||
некоторых размеров после прореживания остается больше 512 строк. Исходник также
|
||||
предупреждает о риске пропустить говорящего с малой долей речи
|
||||
([`subsampleAffinityMat`](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/nemo/collections/asr/parts/utils/offline_clustering.py#L1043-L1066)).
|
||||
Для коротких записей NeMo добавляет случайные якорные эмбеддинги и повторяет
|
||||
оценку. Такое расширение выходит за пределы минимального ядра статьи
|
||||
([исходник](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/nemo/collections/asr/parts/utils/offline_clustering.py#L536-L595)).
|
||||
|
||||
Полная NME-SC после оценки `N` еще раз раскладывает лапласиан, строит
|
||||
спектральные эмбеддинги и запускает k-means. Для этого тикета такая замена
|
||||
итогового кластеризатора не нужна: локальные тесты уже показывают полезный путь
|
||||
`FastClustering` с известным числом говорящих. Нужна только функция
|
||||
`embeddings -> N`.
|
||||
|
||||
## Рекомендуемая граница ответственности
|
||||
|
||||
Здесь речь не об отдельном счетчике, а о способе соединить оценку `N` со
|
||||
штатным кластеризатором:
|
||||
|
||||
```text
|
||||
segmentation -> embeddings +-> счетчик NME -> N
|
||||
+-> FastClustering(features, num_clusters=N)
|
||||
-> штатная финализация sherpa
|
||||
```
|
||||
|
||||
Плюсы:
|
||||
|
||||
- порог метода полной связи больше не определяет число голосовых кластеров;
|
||||
- CAMPPlus и TitaNet используют один модельный проход;
|
||||
- итоговое назначение меток остается в уже принятом `FastClustering`;
|
||||
- на одной матрице можно независимо сравнить несколько счетчиков.
|
||||
|
||||
Минус один, но архитектурный: без нового промежуточного интерфейса текущий
|
||||
`process()` не позволяет вставить счетчик между вычислением матрицы и
|
||||
кластеризацией.
|
||||
|
||||
## Зависимости, лицензии и цена
|
||||
|
||||
Штатная установка NeMo требует среди прочего NumPy, scikit-learn и PyTorch
|
||||
([`pyproject.toml`](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/pyproject.toml)).
|
||||
|
||||
| Вариант | Зависимости прототипа | Работа после эмбеддингов | Ограничение |
|
||||
|---|---|---|---|
|
||||
| Порог `FastClustering` | новых нет | существующие попарные расстояния и AHC | порог зависит от модели и материала |
|
||||
| Счетчик eigengap | NumPy; официальный модуль 3D-Speaker целиком не импортируется | матрица `M x M`, одно собственное разложение | фиксированное прореживание остается параметром |
|
||||
| Полная NME-SC NeMo | `nemo-toolkit`, PyTorch, scikit-learn и другие зависимости NeMo | несколько разложений, затем спектральные эмбеддинги и k-means | меняет итоговый кластеризатор и слишком тяжела для счетчика |
|
||||
| Счетчик NME + `FastClustering` | NumPy | несколько разложений, затем штатный AHC по известному `N` | нужен внутренний интерфейс sherpa и проверка переноса |
|
||||
|
||||
`FastClustering` уже хранит `M(M-1)/2` расстояний и строит AHC. Счетчик eigengap
|
||||
добавляет плотную матрицу сходства и одно собственное разложение. NME
|
||||
повторяет анализ для набора `p`. Полная спектральная кластеризация также хранит
|
||||
собственные векторы и запускает k-means, поэтому для одной оценки `N` избыточна.
|
||||
В проектных замерах основной расход пока приходится на извлечение эмбеддингов,
|
||||
а не на кластеризацию
|
||||
([профиль стоимости](2026-08-14-diarization-cpu-cost.md),
|
||||
[CPU-тест](../benchmarks/2026-08-30-lightweight-diarization-cpu.md)); это не
|
||||
заменяет отдельного замера NME на фактическом числе строк.
|
||||
|
||||
NumPy сейчас есть только в [lock-файле проекта](../../uv.lock), а не в прямых
|
||||
зависимостях `local-transcriber`. Для стенда версию 2.4.3 нужно закрепить явно.
|
||||
Если счетчик дойдет до поставляемого кода, NumPy потребуется объявить прямой
|
||||
зависимостью времени выполнения.
|
||||
|
||||
sherpa-onnx, 3D-Speaker и NeMo Speech распространяют код под Apache-2.0
|
||||
([sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/LICENSE),
|
||||
[3D-Speaker](https://github.com/modelscope/3D-Speaker/blob/065629c313eaf1a01c65c640c46d77e61e9607b4/LICENSE),
|
||||
[NeMo Speech](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/LICENSE)).
|
||||
Авторская реализация NME-SC опубликована под MIT и направляет пользователей к
|
||||
поддерживаемой версии NeMo
|
||||
([репозиторий авторов](https://github.com/tango4j/Auto-Tuning-Spectral-Clustering/tree/ae97f9be9c33b554205c7192c5137ce8f456c1b7)).
|
||||
Файл кластеризации NeMo дополнительно сохраняет BSD-уведомление scikit-learn,
|
||||
NumPy 2.4.3 использует BSD-3-Clause
|
||||
([лицензия](https://github.com/numpy/numpy/blob/v2.4.3/LICENSE.txt)).
|
||||
Лицензионного запрета на прототип нет. Прямое заимствование кода потребует
|
||||
сохранить его уведомления; реализация по формулам статьи должна явно назвать
|
||||
происхождение алгоритма.
|
||||
|
||||
## Ограничения вывода
|
||||
|
||||
- NME-SC проверялась авторами на другой нарезке и эмбеддингах x-vector. Строки
|
||||
sherpa представляют локальных говорящих в перекрывающихся окнах Pyannote после
|
||||
исключения пересечений речи;
|
||||
переносимость нельзя считать доказанной.
|
||||
- Правильное `N` не гарантирует качественную разметку говорящих. Для TitaNet в
|
||||
режиме известного числа кластеров уже наблюдался малый кластер, поэтому нужны
|
||||
прежние прокси-метрики и слуховая проверка.
|
||||
- У sherpa есть отдельный путь для единственного окна, который обходится без
|
||||
общей матрицы эмбеддингов. Стенд должен сохранить этот частный случай, а также
|
||||
текущую обработку пустого результата и нечисловых эмбеддингов.
|
||||
- Python-модуль `FastClustering` нормирует переданную матрицу на месте;
|
||||
сравниваемым вариантам нужны отдельные C-contiguous копии.
|
||||
- Оцененное `N` нужно проверять до вызова `FastClustering`:
|
||||
`1 <= N <= min(8, M)`, где `M` обозначает число валидных строк. Некорректное
|
||||
значение считается ошибкой ячейки, а не молча ограничивается.
|
||||
- Прореживание строк может потерять голосовой кластер с малой долей речи.
|
||||
Поэтому полный набор строк задает основной результат, а сокращенная матрица
|
||||
годится только для отдельной проверки чувствительности.
|
||||
- `numpy.linalg.eigvalsh` использует BLAS/LAPACK. Без закрепленного числа
|
||||
потоков его процессорное и фактическое время нельзя сравнивать между
|
||||
машинами.
|
||||
|
||||
## Точная граница будущего прототипа
|
||||
|
||||
Production-код, CLI, модели по умолчанию и ADR не менять. Сделать отдельный
|
||||
исследовательский стенд на sherpa-onnx 1.13.5 и сохранить точный хеш коммита
|
||||
экспериментальной сборки рядом с результатом.
|
||||
|
||||
### Внутренний интерфейс sherpa
|
||||
|
||||
В экспериментальной ветке sherpa разделить внутренний `Process` в одном месте:
|
||||
после удаления нечисловых строк и синхронной фильтрации пар
|
||||
`окно x локальный говорящий`, до `clustering_->Cluster`.
|
||||
|
||||
- `Prepare(audio)` либо возвращает готовый результат существующего частного
|
||||
пути, либо владеющий объект `PreparedDiarization`. Объект хранит матрицу
|
||||
`float32`, выровненные пары, локальные покадровые метки, число говорящих по
|
||||
кадрам и длину аудио, необходимые текущей финализации.
|
||||
- Экспериментальный Python-модуль отдает C-contiguous копию матрицы и
|
||||
непрозрачный дескриптор подготовленного состояния. Дескриптор живет только в
|
||||
памяти процесса.
|
||||
- `Finalize(handle, row_labels)` проверяет число меток и выполняет существующие
|
||||
`ConvertChunkSpeakerToCluster`, `ReLabel`, `FinalizeLabels` и `ComputeResult`.
|
||||
Так все счетчики используют одну подготовку и одну штатную финализацию.
|
||||
- Пустая речь, одно окно, отсутствие валидных эмбеддингов и одна валидная строка
|
||||
сохраняют нынешнее поведение. Аудио, текст, матрица и дескриптор не
|
||||
сериализуются.
|
||||
|
||||
### Закрепленные входы и счетчики
|
||||
|
||||
Использовать только модель Pyannote Segmentation 3.0 FP32 `model.onnx`
|
||||
(`220ad67ca923bef2fa91f2390c786097bf305bceb5e261d4af67b38e938e1079`) и две
|
||||
модели эмбеддингов из предыдущих измерений:
|
||||
|
||||
- CAMPPlus zh/en
|
||||
(`aa3cfc16963a10586a9393f5035d6d6b57e98d358b347f80c2a30bf4f00ceba2`);
|
||||
- TitaNet small
|
||||
(`ad4a1802485d8b34c722d2a9d04249662f2ece5d28a7a039063ca22f515a789e`).
|
||||
|
||||
Полные SHA-256 и происхождение уже записаны в
|
||||
[калибровке CAMPPlus](../benchmarks/2026-08-14-diarization-calibration.md#модели)
|
||||
и [исследовании TitaNet](2026-08-29-lightweight-diarization-models.md#проверенные-sha-256).
|
||||
INT8-сегментация в эту матрицу не входит: она меняет локальные покадровые метки
|
||||
и уже отклонена по результатам отдельного CPU-теста.
|
||||
|
||||
На каждой исходной матрице сравнить:
|
||||
|
||||
1. диагностический контроль `FastClustering`: для CAMPPlus пороги `1,05` и
|
||||
`1,10`; для TitaNet использовать наименее плохую, но отклоненную точку
|
||||
`1,10`;
|
||||
2. счетчик eigengap по схеме 3D-Speaker с `pval=0.012`, `min_pnum=6`, без
|
||||
спектральных эмбеддингов и k-means;
|
||||
3. счетчик NME по контракту ниже.
|
||||
|
||||
Контрольные пороги не становятся допустимыми настройками. Они только
|
||||
воспроизводят уже наблюдавшийся переход между лишними кластерами и склейкой.
|
||||
|
||||
### Контракт счетчика NME
|
||||
|
||||
Контракт следует формулам статьи и варианту Sparse-Search-20 из
|
||||
[кода авторов](https://github.com/tango4j/Auto-Tuning-Spectral-Clustering/blob/ae97f9be9c33b554205c7192c5137ce8f456c1b7/spectral_opt.py#L463-L495):
|
||||
|
||||
| Параметр | Значение |
|
||||
|---|---|
|
||||
| Вход | все `M` валидных строк; строки конечны и имеют ненулевую норму |
|
||||
| Матрица сходства | нормировать строки на копии, затем `A = E @ E.T` |
|
||||
| Диапазон `p` | `1..floor(M/4)`; при `M < 6` ячейка счетчика помечается пропуском, а диагностический `FastClustering` выполняется независимо |
|
||||
| Разреженный поиск | все значения, если верхняя граница не больше 20; иначе 20 целых точек `linspace` с обоими концами и удалением дублей |
|
||||
| Бинаризация | сохранить top-`p` в каждой строке, включая диагональ; равные значения разрешать по меньшему индексу столбца |
|
||||
| Граф | `(A_p + A_p.T) / 2`, затем обнулить диагональ |
|
||||
| Лапласиан | ненормированный `L = D - A`, `D_ii = sum_j(abs(A_ij))` |
|
||||
| Собственные значения | `numpy.linalg.eigvalsh`, по возрастанию; `epsilon = 1e-10` |
|
||||
| Оценка `N` | `N = 1 + argmax(gaps[:min(8, M - 1)])`; при равенстве брать наименьший индекс |
|
||||
| Выбор `p` | `argmin` по `(p/M) / (max_gap / (lambda_max + epsilon) + epsilon)`; при равенстве брать наименьшее `p` |
|
||||
| Связность | если выбранный граф несвязен, взять первое большее `p` из той же сетки, которое делает его связным; отсутствие такого `p` считать ошибкой ячейки |
|
||||
|
||||
После коррекции связности использовать `N`, рассчитанное для итогового `p`.
|
||||
Статья и код авторов не задают стабильное разрешение равных значений или
|
||||
поведение при слишком малой матрице; таблица фиксирует эти два локальных
|
||||
правила воспроизводимости. Основной расчет идет по всем строкам, как в
|
||||
авторской реализации.
|
||||
|
||||
До прогона на рабочих записях закрепить две синтетические фикстуры с
|
||||
блочными матрицами сходства и ожидаемыми `N=2` и `N=3`. Они проверяют
|
||||
сортировку и разрешение равных значений в `argmax` и `argmin`.
|
||||
Якорные эмбеддинги, Torch, спектральные эмбеддинги и k-means в этот счетчик не
|
||||
входят.
|
||||
|
||||
Основной результат считать на всех строках. Если `M > 512`, дополнительно
|
||||
повторить NME с регулярным прореживанием NeMo, записать `M` до и после и
|
||||
сравнить `N`. Сокращенная матрица не заменяет полный результат даже при
|
||||
совпадении.
|
||||
|
||||
### Проверка и граница решения
|
||||
|
||||
Для каждой модели один раз подготовить матрицу на тех же трех пятиминутных
|
||||
[контрольных записях](../benchmarks/2026-08-30-lightweight-diarization-cpu.md)
|
||||
и на ее независимых копиях выполнить все счетчики. Критерии качества:
|
||||
|
||||
- оцененное число голосовых кластеров точно равно `3 / 2 / 2` на трех
|
||||
контрольных записях для каждой модели отдельно; содержательные кластеры не
|
||||
компенсируют ошибку счета;
|
||||
- метки строк и финальный результат для оцененного `N` совпадают с отдельным
|
||||
вызовом известного `N` на той же матрице;
|
||||
- повторный счет на той же матрице дает те же `p`, `N` и метки;
|
||||
- сохранены прежняя метрика `mapped speaker purity`, число слов без говорящего и
|
||||
диагностика малых кластеров; прогон CAMPPlus и TitaNet заканчивается слуховой
|
||||
проверкой.
|
||||
|
||||
Для каждого счетчика отдельно записать фактическое и процессорное время,
|
||||
`peak RSS`, форму матрицы, конфигурацию NumPy/BLAS и эффективное число потоков.
|
||||
Процесс запускать с одним потоком BLAS/LAPACK; способ ограничения и вывод
|
||||
`numpy.show_config()` входят в результат.
|
||||
|
||||
Числовой гейт времени и памяти эта записка не задает: его определит
|
||||
[Зафиксировать аппаратную матрицу и бюджет памяти](https://git.dementev.space/ddmitry/local-transcriber/issues/35),
|
||||
а общую матрицу соберет
|
||||
[Собрать минимальную матрицу следующего CPU-эксперимента](https://git.dementev.space/ddmitry/local-transcriber/issues/34).
|
||||
До этих решений время и память служат измерениями, но не основанием объявить
|
||||
счетчик принятым.
|
||||
|
||||
Если счетчики eigengap и NME проходят критерии качества, в следующую матрицу
|
||||
идет более дешевый. Если проходит только NME, отдельно решается перенос чистой
|
||||
NumPy-реализации в production. Если оба ошибаются, CAMPPlus и TitaNet остаются
|
||||
кандидатами только для явно заданного числа говорящих.
|
||||
|
||||
Прототип проверяет переносимость оценки `N` на внутренние строки sherpa и цену
|
||||
счетчика. Он не выбирает новую модель по умолчанию, не заменяет
|
||||
`FastClustering` и не расширяет продуктовый контракт.
|
||||
@@ -0,0 +1,209 @@
|
||||
# Поддерживаемый шаг окна в sherpa-onnx
|
||||
|
||||
**Дата:** 2026-08-30
|
||||
|
||||
**Статус:** исследовательская записка. Production-зависимости и код не менялись.
|
||||
|
||||
## Вопрос и ответ
|
||||
|
||||
Первый опубликованный релиз, в котором `window_shift_ratio` доступен из
|
||||
готового Python wheel для offline speaker diarization: **sherpa-onnx 1.13.6**
|
||||
от 18 августа 2026 года. В нем параметр входит в публичный Python-конструктор
|
||||
`OfflineSpeakerSegmentationPyannoteModelConfig` и доступен как изменяемое поле.
|
||||
Это подтверждают [binding тега
|
||||
v1.13.6](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.6/sherpa-onnx/python/csrc/offline-speaker-diarization.cc#L50-L59),
|
||||
[пример Python API](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.6/python-api-examples/offline-speaker-diarization.py#L62-L80)
|
||||
и [состав релиза](https://github.com/k2-fsa/sherpa-onnx/releases/tag/v1.13.6).
|
||||
Готовые wheels появились на PyPI в тот же день
|
||||
([метаданные 1.13.6](https://pypi.org/pypi/sherpa-onnx/1.13.6/json)). На дату
|
||||
исследования 1.13.6 также остается [текущей версией пакета на
|
||||
PyPI](https://pypi.org/pypi/sherpa-onnx/json).
|
||||
|
||||
С 1.13.5 можно перейти **обычным обновлением до 1.13.6**. Частный binding,
|
||||
backport и новый запрос upstream не нужны: нужный follow-up уже принят и
|
||||
опубликован upstream. Модели и рабочее значение `0,1` при этом не меняются.
|
||||
|
||||
## Хронология
|
||||
|
||||
| Дата и версия | Что было доступно |
|
||||
|---|---|
|
||||
| До 23.07.2026 | Шаг был зашит в C++ как 10% окна. Это исходная проблема, описанная upstream в [PR #3769](https://github.com/k2-fsa/sherpa-onnx/pull/3769). |
|
||||
| 23.07.2026, commit [`797e6e9`](https://github.com/k2-fsa/sherpa-onnx/commit/797e6e9) | `window_shift_ratio` добавлен в C++-ядро и CLI. Допустимый диапазон: `(0, 1]`, значение по умолчанию: `0,1`. C API и языковые binding намеренно оставлены для follow-up ([PR #3769](https://github.com/k2-fsa/sherpa-onnx/pull/3769)). |
|
||||
| 11.08.2026, v1.13.5 | Первый релиз C++-ядра и CLI с параметром ([release notes](https://github.com/k2-fsa/sherpa-onnx/releases/tag/v1.13.5)). Python binding этой версии принимает только `model` и не экспортирует поле ([исходник v1.13.5](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/python/csrc/offline-speaker-diarization.cc#L50-L57)). |
|
||||
| 13.08.2026, commit [`3e40933`](https://github.com/k2-fsa/sherpa-onnx/commit/3e409338959097c6518998c9b72757db257f5f6f) | Параметр проведен через публичные C/CXX API и языковые binding, включая Python; PR #3870 принят upstream ([описание и мотивировка](https://github.com/k2-fsa/sherpa-onnx/pull/3870)). |
|
||||
| 18.08.2026, v1.13.6 | Первый релиз с публичным Python API. На PyPI есть cp313 и cp314 wheels без отметки yanked для всех платформ проекта ([release](https://github.com/k2-fsa/sherpa-onnx/releases/tag/v1.13.6), [PyPI JSON](https://pypi.org/pypi/sherpa-onnx/1.13.6/json)). |
|
||||
|
||||
Граница проходит между двумя релизами: **1.13.5** дает C++-ядро и CLI, а в
|
||||
**1.13.6** доступны C, CXX wrapper, Python и остальные binding.
|
||||
|
||||
## Публичный контракт 1.13.6
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
pyannote = sherpa_onnx.OfflineSpeakerSegmentationPyannoteModelConfig(
|
||||
model=segmentation_model,
|
||||
window_shift_ratio=0.2,
|
||||
)
|
||||
```
|
||||
|
||||
Конструктор имеет сигнатуру `(model, window_shift_ratio=0.1)`. Объект
|
||||
предоставляет изменяемое поле `.window_shift_ratio`. Прежний вызов только с
|
||||
`model` остается допустимым и сохраняет прежнее поведение
|
||||
([binding](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.6/sherpa-onnx/python/csrc/offline-speaker-diarization.cc#L50-L59)).
|
||||
Нативная валидация принимает только `(0, 1]`; ноль в Python не включает default
|
||||
([конфигурация ядра](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.6/sherpa-onnx/csrc/offline-speaker-segmentation-pyannote-model-config.cc#L24-L39)).
|
||||
|
||||
Параметр читается при создании segmentation-модели. Метод
|
||||
`OfflineSpeakerDiarization.set_config()` обновляет только clustering, поэтому
|
||||
для каждой точки сетки нужен новый diarizer
|
||||
([реализация](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.6/sherpa-onnx/csrc/offline-speaker-diarization-pyannote-impl.h#L87-L94)).
|
||||
|
||||
### C и C++
|
||||
|
||||
- Публичная C-структура получила
|
||||
`SherpaOnnxOfflineSpeakerSegmentationPyannoteModelConfig.window_shift_ratio`.
|
||||
Значение должно лежать в `(0, 1]`; только C-конвертер трактует `<= 0` как
|
||||
незаданное и подставляет `0,1`, чтобы не сломать вызывающий код с
|
||||
zero-initialized struct
|
||||
([C header](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.6/sherpa-onnx/c-api/c-api.h#L3839-L3846),
|
||||
[конвертер](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.6/sherpa-onnx/c-api/c-api.cc#L3145-L3156)).
|
||||
- Публичный CXX wrapper получил поле `float window_shift_ratio = 0.1f`
|
||||
([cxx-api.h](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.6/sherpa-onnx/c-api/cxx-api.h#L1911-L1917)).
|
||||
- Внутренний C++-контракт ядра существовал уже в 1.13.5, но не давал
|
||||
приложению на Python поддерживаемого пути. Подменять им binding больше нет
|
||||
смысла.
|
||||
|
||||
## Совместимость wheel и зависимостей
|
||||
|
||||
Проект требует Python `>=3.13` и заявляет Linux, Windows/WSL2 и macOS
|
||||
([pyproject.toml](../../pyproject.toml), [PRD](../PRD.md#42-требования-к-окружению),
|
||||
[таблица платформ](../../README.md#платформы)). У 1.13.6 матрица нужных wheels
|
||||
совпадает с 1.13.5.
|
||||
|
||||
| Среда проекта | `sherpa-onnx` 1.13.6 | `sherpa-onnx-core` 1.13.6 | Вывод |
|
||||
|---|---|---|---|
|
||||
| CPython 3.13/3.14, Linux x86-64 и WSL2 | `manylinux2014_x86_64` / `manylinux_2_17_x86_64` | `py3-none-manylinux2014_x86_64` | Готовый комплект есть |
|
||||
| CPython 3.13/3.14, Linux AArch64 | `manylinux2014_aarch64` / `manylinux_2_17_aarch64` | `py3-none-manylinux2014_aarch64` | Готовый комплект есть |
|
||||
| CPython 3.13/3.14, Windows x86-64 | `win_amd64` | `py3-none-win_amd64` | Готовый комплект есть |
|
||||
| CPython 3.13/3.14, Windows ARM64 | `win_arm64` | `py3-none-win_arm64` | Готовый комплект есть; проектом отдельно не проверен |
|
||||
| CPython 3.13/3.14, macOS x86-64 | `macosx_10_15_x86_64` или `universal2` | те же platform tags | Готовый комплект есть; macOS в проекте не проходил ручную приемку |
|
||||
| CPython 3.13/3.14, macOS ARM64 | `macosx_11_0_arm64` или `universal2` | те же platform tags | Готовый комплект есть; macOS в проекте не проходил ручную приемку |
|
||||
|
||||
Linux-поставка рассчитана на glibc 2.17 и новее: `musllinux` wheels в релизе
|
||||
нет. Поэтому таблица не подтверждает готовую бинарную установку на Alpine или
|
||||
другом Linux с musl.
|
||||
|
||||
Источник файлов основной поставки: [PyPI 1.13.6
|
||||
JSON](https://pypi.org/pypi/sherpa-onnx/1.13.6/json), нативной части:
|
||||
[PyPI core 1.13.6 JSON](https://pypi.org/pypi/sherpa-onnx-core/1.13.6/json).
|
||||
Метаданные основного пакета объявляют `Requires-Python >=3.7` и единственную
|
||||
зависимость `sherpa-onnx-core==1.13.6`; core дополнительных `Requires-Dist` не
|
||||
объявляет. Универсального `abi3` wheel нет: при сохранении открытой границы
|
||||
Python `>=3.13` готовая бинарная установка на будущем CPython 3.15 потребует
|
||||
нового upstream wheel. Исходный архив опубликован, но локальная нативная сборка
|
||||
для 3.15 в это исследование не входила.
|
||||
|
||||
Для обновления проекта нужно синхронно поменять три места: пин
|
||||
`sherpa-onnx==1.13.6`, uv override на `sherpa-onnx-core==1.13.6` и lock-файл.
|
||||
Текущий комментарий рядом с override уже требует проверку `uv tree --locked
|
||||
--package sherpa-onnx` ([pyproject.toml](../../pyproject.toml)). Других новых
|
||||
Python-зависимостей между 1.13.5 и 1.13.6 нет
|
||||
([метаданные 1.13.5](https://pypi.org/pypi/sherpa-onnx/1.13.5/json),
|
||||
[метаданные 1.13.6](https://pypi.org/pypi/sherpa-onnx/1.13.6/json)).
|
||||
|
||||
### Риски совместимости
|
||||
|
||||
- Для Python изменение обратно совместимо по исходному коду: новый аргумент
|
||||
необязательный, default равен прежнему зашитому значению. Upstream отдельно
|
||||
проверил одинаковый шаг в сэмплах для стандартного окна при `0,1`
|
||||
([PR #3769](https://github.com/k2-fsa/sherpa-onnx/pull/3769)).
|
||||
- C-структура стала больше. Это изменение layout и потенциальный ABI-разрыв для
|
||||
стороннего бинарника, собранного с header 1.13.5 и загружающего библиотеку
|
||||
1.13.6 без пересборки. Проект использует согласованные Python/core wheels и
|
||||
такого бинарника не имеет. Zero-initialized C-код после пересборки сохраняет
|
||||
default `0,1` ([PR #3870](https://github.com/k2-fsa/sherpa-onnx/pull/3870)).
|
||||
- Остальные изменения 1.13.6 относятся к сборке Android/Java, SPM,
|
||||
Flutter/Dart и примерам VAD+ASR. Релиз не заявляет удаления Python API или
|
||||
смены формата моделей ([release notes](https://github.com/k2-fsa/sherpa-onnx/releases/tag/v1.13.6)).
|
||||
- Временное окружение CPython 3.13.14 на Linux x86-64 установило согласованную
|
||||
пару `sherpa-onnx==1.13.6` и `sherpa-onnx-core==1.13.6`. Импорт и создание
|
||||
конфигураций со всеми пятью значениями сетки прошли. Для Windows и macOS пока
|
||||
подтверждена только upstream-поставка; при обновлении проекта нужен отдельный
|
||||
smoke test Windows x86-64 и повтор Linux-проверки из lock-файла.
|
||||
|
||||
## Что именно меняет параметр
|
||||
|
||||
При загрузке модели движок вычисляет `window_shift = int(ratio * window_size)`
|
||||
и при debug печатает фактическое значение. Затем запись режется на окна по этому
|
||||
шагу; для записи длиннее одного окна число segmentation-запусков равно
|
||||
`floor((n - W) / S) + 1` плюс одно дополненное нулями окно, если есть остаток
|
||||
([расчет шага](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.6/sherpa-onnx/csrc/offline-speaker-segmentation-pyannote-model.cc#L92-L123),
|
||||
[цикл окон](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.6/sherpa-onnx/csrc/offline-speaker-diarization-pyannote-impl.h#L278-L337)).
|
||||
|
||||
Для Pyannote Segmentation 3.0 с окном `160000` сэмплов при 16 кГц сетка дает:
|
||||
|
||||
| Ratio | Шаг, сэмплов | Шаг, с | Окон на ровных 300 с | Теоретическая доля окон к `0,1` |
|
||||
|---:|---:|---:|---:|---:|
|
||||
| 0,10 | 16 000 | 1,0 | 291 | 1,00 |
|
||||
| 0,15 | 24 000 | 1,5 | 195 | 0,67 |
|
||||
| 0,20 | 32 000 | 2,0 | 146 | 0,50 |
|
||||
| 0,25 | 40 000 | 2,5 | 117 | 0,40 |
|
||||
| 0,50 | 80 000 | 5,0 | 59 | 0,20 |
|
||||
|
||||
Это не прогноз полного ускорения. После segmentation движок создает отдельное
|
||||
задание embedding для каждой достаточно длинной пары `(окно, локальный
|
||||
говорящий)`. Их число зависит от речи и перекрытий
|
||||
([формирование пар](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.6/sherpa-onnx/csrc/offline-speaker-diarization-pyannote-impl.h#L408-L484),
|
||||
[цикл embedding](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.6/sherpa-onnx/csrc/offline-speaker-diarization-pyannote-impl.h#L510-L564)).
|
||||
Upstream получил на одном английском файле 1,48× при `0,15` и 2,00× при `0,20`,
|
||||
но сам ограничил вывод этим материалом; переносить коэффициенты на русские
|
||||
созвоны нельзя ([измерение в PR #3769](https://github.com/k2-fsa/sherpa-onnx/pull/3769)).
|
||||
|
||||
## Точная граница следующего прототипа
|
||||
|
||||
Прототип должен отвечать только на вопрос, действительно ли сетка
|
||||
`0,1 / 0,15 / 0,2 / 0,25 / 0,5` уменьшает работу текущей связки без
|
||||
неприемлемой потери разметки говорящих.
|
||||
|
||||
1. Использовать обычные wheels 1.13.6 и текущую связку Pyannote FP32 +
|
||||
WeSpeaker, 8 потоков, порог `0,89`, автоматическое число голосовых кластеров.
|
||||
Не смешивать в эту сетку INT8, TitaNet, новый порог или параллельный ASR.
|
||||
2. Добавить ratio только в benchmark-конфигурацию. Для каждой ячейки создавать
|
||||
новый diarizer; production CLI, config cascade и default `0,1` не менять.
|
||||
3. Сначала выполнить публичный smoke test: импорт, сигнатура, `validate()` для
|
||||
пяти значений и один файл длиннее 10 секунд. Затем отдельная benchmark-задача
|
||||
может использовать те же три контрольных фрагмента, что предыдущий CPU-тест,
|
||||
с прежними правилами хранения данных.
|
||||
4. В каждой ячейке сохранить счетчики механизма:
|
||||
- фактический `window_shift` в сэмплах из debug-журнала;
|
||||
- вычисленное число segmentation-окон по длине входа и формуле upstream;
|
||||
- финальный `num_total_chunks` progress callback как число embedding-заданий.
|
||||
Название callback вводит в заблуждение: реализация вызывает его внутри
|
||||
цикла по `(окно, локальный говорящий)`. В segmentation-цикле вызова нет
|
||||
([исходник](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.6/sherpa-onnx/csrc/offline-speaker-diarization-pyannote-impl.h#L517-L564));
|
||||
- время segmentation, embedding, clustering, total и RTF из штатного
|
||||
debug-профиля, а также wall time, CPU и peak RSS процесса.
|
||||
5. Чтобы не принять ускорение ценой поломки результата, сохранить прежние
|
||||
контрольные показатели: число голосовых кластеров `3 / 2 / 2`, mapped
|
||||
speaker purity там, где есть опорная разметка, слова без говорящего,
|
||||
максимальный остаток сверх ожидаемых кластеров, число малых кластеров и
|
||||
неизменность распознанного текста. Для кандидата после сетки нужна отдельная
|
||||
слуховая проверка коротких ответов, смен говорящего и перекрывающейся речи.
|
||||
|
||||
Не входят в прототип: выбор нового production default, пользовательский
|
||||
параметр CLI, повторная настройка clustering, сравнение embedding-моделей и
|
||||
параллельный запуск ASR. Эти решения возможны только после сетки. Ускорение
|
||||
должно сопровождаться ожидаемым снижением числа segmentation-окон и
|
||||
embedding-заданий. По одному wall time нельзя связать ускорение с шагом окна.
|
||||
|
||||
## Рекомендация
|
||||
|
||||
Выбрать **обычное обновление до 1.13.6** и сначала проверить его в отдельном
|
||||
прототипе. Обновление дает проекту штатную ручку для сокращения основной
|
||||
CPU-работы; свежесть зависимости сама по себе здесь не аргумент. Малый backport
|
||||
дублировал бы уже выпущенный upstream-код и потребовал бы собственного нативного
|
||||
wheel.
|
||||
Запрос upstream уже фактически выполнен PR #3870. Production default оставить
|
||||
`0,1`, пока сетка не подтвердит одновременно снижение работы, приемлемую
|
||||
скорость и сохранение качества разметки говорящих.
|
||||
@@ -0,0 +1,166 @@
|
||||
# Кандидаты на ASR-модель для русских встреч
|
||||
|
||||
**Дата:** 2026-08-31
|
||||
|
||||
**Статус:** кабинетное исследование по первичным источникам; локально новые модели еще не запускались
|
||||
|
||||
**Область исследования:** длинные русские встречи с английскими IT-терминами, локальная обработка и обязательные пословные таймкоды
|
||||
|
||||
## Исследовательский вопрос
|
||||
|
||||
Есть ли среди Nemotron 3.5 ASR Streaming 0.6B, Qwen3-ASR 0.6B, Canary 180M Flash и Cohere Transcribe кандидат, который лучше текущей GigaAM v3 E2E RNN-T подходит для локальной расшифровки длинных русских встреч с английскими IT-терминами?
|
||||
|
||||
## Контекст
|
||||
|
||||
Текущий профиль проекта, `gigaam-v3-e2e-rnnt`, уже встроен через `onnx-asr` и выбран по итогам проектного benchmark. Он дает быстрый русский E2E-текст с пунктуацией. Основная известная слабость - нестабильное распознавание английских терминов и названий. Подробные результаты приведены ниже.
|
||||
|
||||
Опубликованные WER разных моделей нельзя складывать в общий рейтинг. Корпуса, языки, нормализация и режимы распознавания различаются. Обоснованный вывод о преимуществе другой модели возможен только после прогона на проектных записях.
|
||||
|
||||
## Известные факты
|
||||
|
||||
### Сводка
|
||||
|
||||
| Модель | Русский | Таймкоды в практичном локальном пути | Размер локальных весов | Путь интеграции | Ключевое ограничение |
|
||||
|---|---|---|---:|---|---|
|
||||
| GigaAM v3 E2E RNN-T INT8 | Да, основной язык | Да, текущий адаптер `onnx-asr` | около 227 MB файлов ONNX | Уже встроена | Английские термины распознаются нестабильно |
|
||||
| Nemotron 3.5 ASR Streaming 0.6B Q8 | Да, `ru-RU` в основном tier | Да, NeMo-Speech.cpp JSON содержит `words[]` | 742 MB | Новый backend поверх NeMo-Speech.cpp | Нет сопоставимых CPU-замеров |
|
||||
| Qwen3-ASR 0.6B Q8 | Да, один из 30 языков | ASR не дает; нужен отдельный Qwen3-ForcedAligner 0.6B | 811 MB только ASR | Новый OpenVINO или native путь плюс aligner | Таймкоды требуют второго модельного этапа |
|
||||
| Canary 180M Flash Q8 | Нет, только `en/de/es/fr` | В Handy и ONNX-порте нет | 208 MB GGUF, около 214 MB ONNX | Raw ONNX близок к текущему адаптеру | Нет русского; доступные порты теряют таймкоды |
|
||||
| Cohere Transcribe Q5 | Нет, 14 языков без русского | Нет у upstream и Handy | 1.77 GB | Новый runtime и внешний VAD | Нет русского и таймкодов |
|
||||
|
||||
Размер файла не равен peak RAM или VRAM. Для новых моделей эти значения надо измерить отдельно.
|
||||
|
||||
### Режим работы и контракт результата
|
||||
|
||||
| Модель | Практический режим | Длинные записи | Контекст терминов | Результат |
|
||||
|---|---|---|---|---|
|
||||
| GigaAM v3 E2E RNN-T | Offline-файлы | Проект режет запись через Silero VAD | Текущий адаптер не передает словарь или контекст | Русский текст с регистром, пунктуацией, нормализацией и текущими word timestamps; confidence от модели не сохраняется |
|
||||
| Nemotron 3.5 | Offline и настоящий cache-aware streaming | NeMo-Speech.cpp принимает файл целиком или поток | Word boosting в NeMo-Speech.cpp | Пунктуация и регистр; auto language tag; `words[]` с временем и confidence; полная ITN требует отдельной grammar |
|
||||
| Qwen3-ASR 0.6B | Upstream: offline и vLLM streaming; Handy: только offline | Handy принимает до примерно 87 минут; aligner заявлен до пяти минут, официальный wrapper режет timestamp-вход по 180 секунд | Официальный runtime принимает свободный `context`; в текущем `transcribe.cpp` входа нет | Текст и определенный язык; timestamps только через отдельный ForcedAligner, без confidence; строгий ITN-контракт не описан |
|
||||
| Canary 180M Flash | Offline, короткие реплики | Вход короче 40 секунд; long-form через chunk-and-stitch | Сопоставимый механизм не заявлен | Переключаемые пунктуация и регистр; upstream дает экспериментальные word/segment timestamps через вспомогательный CTC-aligner, доступные порты его теряют |
|
||||
| Cohere Transcribe | Monolingual offline; incremental streaming не документирован | Transformers автоматически режет и собирает длинное аудио | Сопоставимый механизм не заявлен | Переключаемая пунктуация; язык надо указать; timestamps, diarization и auto language ID отсутствуют |
|
||||
|
||||
Источники по режимам и результату: [Nemotron model card](https://huggingface.co/nvidia/nemotron-3.5-asr-streaming-0.6b), [NeMo-Speech.cpp API](https://github.com/NVIDIA/NeMo-Speech.cpp/blob/main/docs/api.md), [Qwen runtime](https://github.com/QwenLM/Qwen3-ASR/blob/main/qwen_asr/inference/qwen3_asr.py), [Qwen transcribe.cpp](https://github.com/handy-computer/transcribe.cpp/blob/main/docs/models/qwen3-asr.md), [Canary model card](https://huggingface.co/nvidia/canary-180m-flash), [Canary implementation](https://github.com/NVIDIA-NeMo/Speech/blob/main/nemo/collections/asr/models/aed_multitask_models.py), [Cohere model card](https://huggingface.co/CohereLabs/cohere-transcribe-03-2026).
|
||||
|
||||
Сопоставимого word-confidence между моделями нет. NeMo-Speech.cpp возвращает поле `confidence`, но его калибровка не заявлена. Текущий GigaAM-адаптер выбрасывает token log probabilities, Qwen ForcedAligner возвращает только текст и время, Canary и Cohere не документируют confidence как часть результата.
|
||||
|
||||
### Поставка и лицензии
|
||||
|
||||
| Модель | Полный локальный состав | Лицензия и доступ | Условие поставки |
|
||||
|---|---|---|---|
|
||||
| GigaAM | Около 227 MB ONNX плюс уже используемые `onnx-asr`, ONNX Runtime и Silero VAD | MIT | При распространении нужно сохранить текст лицензии |
|
||||
| Nemotron 3.5 | 742 MB Q8 GGUF плюс NeMo-Speech.cpp; VAD и полная ITN требуют дополнительных моделей или файлов | Веса OpenMDW-1.1, runtime Apache-2.0 | При распространении весов надо сохранить лицензию и notices |
|
||||
| Qwen3-ASR 0.6B | 811 MB Q8 только для ASR; timestamp-профилю нужен еще Qwen3-ForcedAligner 0.6B, готового GGUF-пути для него нет | Apache-2.0, upstream не gated | Профиль с таймкодами включает второй набор весов; сохраняются license/notices Apache-2.0 |
|
||||
| Canary 180M Flash | 208 MB Q8 GGUF или около 214 MB ONNX дают текст без таймкодов; полный `.nemo` 737 MB содержит вспомогательный aligner и требует NeMo/PyTorch | CC-BY-4.0 | При поставке нужна атрибуция; timestamp-профиль перестает быть компактным |
|
||||
| Cohere Transcribe | 1.77 GB Q5 GGUF; upstream BF16 занимает 4.13 GB | Apache-2.0; официальный Hugging Face repo gated | Автоматическая загрузка официальных весов требует пользовательского доступа |
|
||||
|
||||
Размеры и условия: [GigaAM ONNX](https://huggingface.co/istupakov/gigaam-v3-onnx/tree/main), [GigaAM](https://github.com/salute-developers/GigaAM), [Nemotron files](https://huggingface.co/nvidia/nemotron-3.5-asr-streaming-0.6b/tree/main), [OpenMDW 1.1](https://openmdw.ai/license/1-1/), [Qwen ASR](https://huggingface.co/Qwen/Qwen3-ASR-0.6B), [Qwen ForcedAligner](https://huggingface.co/Qwen/Qwen3-ForcedAligner-0.6B), [Canary](https://huggingface.co/nvidia/canary-180m-flash), [Cohere](https://huggingface.co/CohereLabs/cohere-transcribe-03-2026).
|
||||
|
||||
## Результаты по моделям
|
||||
|
||||
### GigaAM v3 E2E RNN-T
|
||||
|
||||
На трех реальных встречах общей длительностью 43:37 текущая GigaAM v3 E2E RNN-T INT8 показала `11.54x RTFx`, `26.9%` сравнительного WER и 290 знаков пунктуации на 1000 слов. Внешние транскрипты были неточным ориентиром, поэтому WER полезен только для сравнения прогонов на этих же файлах. [Проектный benchmark](../benchmarks/2026-08-11-gigaam-model-comparison.md#сводка-по-трём-записям)
|
||||
|
||||
Сильные стороны baseline:
|
||||
|
||||
- зрелая интеграция через `onnx-asr`, Silero VAD и обязательные token/word timestamps ([текущий адаптер](../../src/local_transcriber/backends/onnx_asr.py));
|
||||
- русский E2E-текст с регистром, пунктуацией и нормализацией;
|
||||
- компактные INT8-веса и измеренная CPU-скорость.
|
||||
|
||||
Известная слабость: английские термины и названия передаются нестабильно. В benchmark встречались варианты `Fine BI`, `FNB`, `API`, `AP`, `пи`, а `edge cases` часто превращался в кириллическую фонетическую запись. Ценность нового кандидата для проекта зависит прежде всего от улучшения этого среза без потери обычной русской речи.
|
||||
|
||||
### Nemotron 3.5 ASR Streaming 0.6B
|
||||
|
||||
Точная модель: [`nvidia/nemotron-3.5-asr-streaming-0.6b`](https://huggingface.co/nvidia/nemotron-3.5-asr-streaming-0.6b), checkpoint `nemotron-3.5-asr-streaming-0.6b-v1`, выпущен 4 июня 2026 года. Это Cache-Aware FastConformer-RNNT на 600 млн параметров с 32 готовыми locale и еще восемью после адаптации. `ru-RU`, `en-US` и `en-GB` входят в основной transcription-ready tier. Модель поддерживает offline-распознавание и streaming с задержкой от 80 ms до 1.12 s, пунктуацию, регистр и auto language ID. [Model card](https://huggingface.co/nvidia/nemotron-3.5-asr-streaming-0.6b)
|
||||
|
||||
#### Практическая совместимость
|
||||
|
||||
Официальный [NeMo-Speech.cpp](https://github.com/NVIDIA/NeMo-Speech.cpp) работает на CPU, CUDA, Vulkan и Metal. Он принимает файлы и поток, а JSON/SRT/VTT-вывод содержит слова с началом, концом и confidence. Q8 GGUF занимает 742 MB. Для первого прототипа достаточно subprocess и JSON; долгоживущий процесс или C API понадобятся позже, если загрузка модели на каждый файл окажется дорогой. Runtime пока молодой: v0.1.0 выпущен 19 августа 2026 года, его API еще может меняться. [CLI и формат вывода](https://github.com/NVIDIA/NeMo-Speech.cpp/blob/main/docs/cli.md#subtitles-and-structured-output), [релиз v0.1.0](https://github.com/NVIDIA/NeMo-Speech.cpp/releases/tag/v0.1.0)
|
||||
|
||||
Runtime также поддерживает per-request word boosting для cache-aware RNN-T. [Матрица customization](https://github.com/NVIDIA/NeMo-Speech.cpp/blob/main/docs/asr/customization.md#feature-matrix)
|
||||
|
||||
#### Ограничения опубликованных данных
|
||||
|
||||
- NVIDIA приводит для FLEURS Russian WER `9.17%` с `ru-RU` и `10.03%` с auto при контексте 1.12 s. Эти числа подтверждают рабочий русский, но не превосходство над GigaAM на встречах.
|
||||
- В обучении были code-switched samples, однако отдельного теста для внутрифразовой смеси `ru/en` нет. [Обсуждение модели](https://huggingface.co/nvidia/nemotron-3.5-asr-streaming-0.6b/discussions/2)
|
||||
- H100 throughput из model card относится к высокой конкурентности. CPU-скорость и peak RSS на сопоставимом desktop-процессоре не опубликованы.
|
||||
- Пунктуация и регистр встроены. Полная inverse text normalization требует отдельной grammar в NeMo-Speech.cpp; по числам, датам и сокращениям паритет с GigaAM пока не доказан. [HTTP API](https://github.com/NVIDIA/NeMo-Speech.cpp/blob/main/docs/api.md#post-v1audiotranscriptions)
|
||||
|
||||
Текущий `onnx-asr` Nemotron 3.5 не поддерживает. Alias в каталоге проекта недостаточен, нужен адаптер нового runtime.
|
||||
|
||||
#### Вывод
|
||||
|
||||
Nemotron ближе остальных новых кандидатов к полному контракту проекта. Он не доказал превосходство по качеству и CPU-скорости, но уже имеет практический путь к русскому тексту, длинным файлам и пословным таймкодам.
|
||||
|
||||
### Qwen3-ASR 0.6B
|
||||
|
||||
Точная upstream-модель: [`Qwen/Qwen3-ASR-0.6B`](https://huggingface.co/Qwen/Qwen3-ASR-0.6B). Handy использует Q8 GGUF размером 811 MB, собранный из зафиксированной revision `5eb144179a02acc5e5ba31e748d22b0cf3e303b0`. Несмотря на имя `0.6B`, Hugging Face показывает около 0.9 млрд параметров всего checkpoint: к Qwen3-0.6B добавлены аудиоэнкодер и projector. [Описание порта transcribe.cpp](https://github.com/handy-computer/transcribe.cpp/blob/main/docs/models/qwen3-asr-0.6b.md), [файлы upstream](https://huggingface.co/Qwen/Qwen3-ASR-0.6B/tree/main)
|
||||
|
||||
Модель поддерживает 30 языков, включая русский и английский, 22 китайских диалекта и автоматическое определение языка. Один checkpoint рассчитан на offline и streaming. [Model card](https://huggingface.co/Qwen/Qwen3-ASR-0.6B)
|
||||
|
||||
#### Контекст запроса
|
||||
|
||||
Qwen использует audio-language-model архитектуру. Официальный runtime принимает свободную строку `context` на каждый запрос и помещает ее в системное сообщение. Так можно передать названия продуктов или тему встречи. В основном `transcribe.cpp` этот вход пока отсутствует; его добавляет открытый [PR #144](https://github.com/handy-computer/transcribe.cpp/pull/144). Наиболее удобный CPU-порт пока теряет эту возможность upstream. [Qwen inference](https://github.com/QwenLM/Qwen3-ASR/blob/main/qwen_asr/inference/qwen3_asr.py)
|
||||
|
||||
Отдельного результата по внутрифразовой смеси `ru/en` нет. Официальный русскоязычный пример monolingual; cross-language demo опубликован для 1.7B и другой группы языков. [Qwen3-ASR blog](https://qwen.ai/blog?id=qwen3asr)
|
||||
|
||||
Официальные aggregate WER для Qwen3-ASR 0.6B составляют `12.75` на CommonVoice, `15.84` на MLC-SLM и `7.57` на FLEURS. В эти наборы входит русский, но отдельный русский результат не опубликован. На тех же aggregate-срезах модель не всегда лучше Whisper large-v3: например, FLEURS `7.57` против `5.27`. Высокая отметка качества в Handy не доказывает выигрыш на русских встречах. [Evaluation table](https://huggingface.co/Qwen/Qwen3-ASR-0.6B#evaluation)
|
||||
|
||||
Официальные `2000x` относятся к vLLM BF16 при concurrency 128 и CUDA Graph. Это серверный GPU-throughput, не скорость одного файла на CPU. В transcribe.cpp на Ryzen 7 PRO 4750U Q8 показала около `4.1-4.6x` на двух коротких английских клипах, Vulkan около `7.3-8.7x`. Железо и аудио отличаются от проектного benchmark, поэтому прямое сравнение с `11.54x` GigaAM некорректно. Оно лишь объясняет отметку Handy о сравнительно низкой скорости. [Технический отчет](https://arxiv.org/abs/2601.21337), [локальные замеры порта](https://github.com/handy-computer/transcribe.cpp/blob/main/docs/models/qwen3-asr-0.6b.md#performance)
|
||||
|
||||
#### Таймкоды
|
||||
|
||||
Qwen3-ASR возвращает текст и язык без timestamps. Официальный путь использует отдельную [`Qwen3-ForcedAligner-0.6B`](https://huggingface.co/Qwen/Qwen3-ForcedAligner-0.6B), которая поддерживает русский и английский, заявлена для аудио до пяти минут и выдает word/character alignment. Актуальный Python wrapper режет timestamp-вход на трехминутные чанки. Для часовой встречи потребуется второй набор весов и дополнительный inference.
|
||||
|
||||
Handy/transcribe.cpp работает с Qwen offline и не предоставляет VAD, streaming или timestamps. Официальный streaming доступен только через vLLM, без batching и timestamps; реализация повторно подает накопленное аудио на каждом шаге, то есть не дает cache-aware поведения Nemotron. [Исходный streaming path](https://github.com/QwenLM/Qwen3-ASR/blob/main/qwen_asr/inference/qwen3_asr.py), [ограничения transcribe.cpp](https://github.com/handy-computer/transcribe.cpp/blob/main/docs/models/qwen3-asr.md)
|
||||
|
||||
#### Варианты интеграции
|
||||
|
||||
- **OpenVINO 2026.3:** ранняя поддержка Qwen3-ASR на CPU/GPU уже заявлена. Проект использует совместимую ветку OpenVINO, но текущий backend построен на `WhisperPipeline`. Нужны новый `ASRPipeline`, экспорт модели и проверка, какие сегменты возвращает API. Отдельный вопрос таймкодов остается. [OpenVINO 2026.3 release](https://github.com/openvinotoolkit/openvino/releases/tag/2026.3.0), [supported models](https://github.com/openvinotoolkit/openvino.genai/blob/master/site/docs/supported-models/index.mdx)
|
||||
- **Официальный Python runtime:** Transformers работает offline и поддерживает batch, но добавляет PyTorch/Transformers и не решает timestamps без aligner.
|
||||
- **transcribe.cpp:** дает готовые GGUF и CPU/Vulkan/Metal/CUDA, но потребует нового backend, внешнего VAD и отдельного aligner.
|
||||
|
||||
#### Вывод
|
||||
|
||||
Qwen отвечает языковым требованиям, но не полному контракту результата: обязательные таймкоды добавляют второй модельный этап и усложняют long-form pipeline. Опубликованных данных недостаточно, чтобы оценить, оправдает ли качество текста эту цену.
|
||||
|
||||
### Модели без русского языка
|
||||
|
||||
#### Canary 180M Flash
|
||||
|
||||
[`nvidia/canary-180m-flash`](https://huggingface.co/nvidia/canary-180m-flash) содержит 182 млн параметров и поддерживает только английский, немецкий, испанский и французский. Handy показывает Q8 GGUF 208 MB. Модель умеет ASR на этих языках и перевод между английским и тремя остальными, но автоматического определения языка нет. [Документация Handy](https://handy.computer/docs/models), [порт transcribe.cpp](https://github.com/handy-computer/transcribe.cpp/blob/main/docs/models/canary-180m-flash.md)
|
||||
|
||||
Upstream Canary умеет экспериментальные word/segment timestamps через `transcribe(..., timestamps=True)`. За этим API стоит вспомогательный CTC-aligner внутри полного `.nemo`. Для аудио длиннее 10 секунд NVIDIA рекомендует long-form inference с чанками по 10 секунд. Handy не портирует aligner; готовый ONNX-export для `onnx-asr` содержит только encoder/decoder и возвращает текст без нужных token timestamps. Это нарушает текущий контракт `local-transcriber`, который требует слова со временем. [Canary timestamp usage](https://huggingface.co/nvidia/canary-180m-flash), [NeMo implementation](https://github.com/NVIDIA-NeMo/Speech/blob/main/nemo/collections/asr/models/aed_multitask_models.py), [заметки порта](https://github.com/handy-computer/transcribe.cpp/blob/main/docs/models/canary-180m-flash.md)
|
||||
|
||||
Canary компактна и быстро работает на чистом `en/de/es/fr`, но русский для нее полностью out-of-domain. Она может быть интересна для отдельного English-first профиля, который не входит в область этого исследования.
|
||||
|
||||
#### Cohere Transcribe
|
||||
|
||||
[`CohereLabs/cohere-transcribe-03-2026`](https://huggingface.co/CohereLabs/cohere-transcribe-03-2026) имеет 2 млрд параметров и поддерживает 14 языков, среди которых нет русского. Автоопределения языка, timestamps и diarization нет; incremental audio streaming не документирован. Модель требует language tag, склонна распознавать non-speech и нуждается во внешнем VAD. [Strengths and limitations](https://huggingface.co/CohereLabs/cohere-transcribe-03-2026#strengths-and-limitations)
|
||||
|
||||
Handy использует Q5 GGUF размером 1.77 GB. Это примерно в восемь раз больше ONNX-весов GigaAM. На чистом английском Cohere показывает сильные результаты, но они ничего не говорят о русском. В рассматриваемом русском сценарии тяжелый backend без timestamps не дает полезного профиля. [GGUF-порт](https://github.com/handy-computer/transcribe.cpp/blob/main/docs/models/cohere-transcribe-03-2026.md)
|
||||
|
||||
## Рабочие гипотезы
|
||||
|
||||
- Word boosting в Nemotron может улучшить продуктовые названия и IT-термины, которые GigaAM распознает нестабильно.
|
||||
- Свободный `context` в официальном runtime Qwen может дать похожий выигрыш, если выбранный локальный путь сохранит этот вход.
|
||||
- Мультиязычность Nemotron и Qwen может помочь с английскими вставками, но сама по себе не доказывает устойчивость внутрифразового `ru/en` code-switch.
|
||||
|
||||
Все три гипотезы следуют из возможностей моделей. Результатов на проектных русских встречах для них пока нет.
|
||||
|
||||
## Рекомендация
|
||||
|
||||
Предварительная рекомендация - пока сохранить GigaAM текущим baseline. Nemotron выглядит основным кандидатом для локального сравнения, поскольку поддерживает русский и уже имеет runtime с пословными таймкодами. Qwen стоит рассматривать вторым кандидатом на качество текста; полный timestamp-профиль имеет смысл только при заметном выигрыше распознавания.
|
||||
|
||||
Canary 180M Flash и Cohere Transcribe не стоит включать в сравнение русского профиля, поскольку русский язык у них не поддерживается. Рекомендация сама по себе не меняет конфигурацию или модель по умолчанию.
|
||||
|
||||
## Что неизвестно до локального прогона
|
||||
|
||||
- Выигрывают ли Nemotron или Qwen у GigaAM на длинной русской речи, внутрифразовых `ru/en` переключениях и IT-терминах. Опубликованные WER этого не показывают.
|
||||
- Каковы CPU-скорость, время загрузки и peak RSS полного рабочего профиля. Для Qwen сюда входят ASR и ForcedAligner, если нужны таймкоды.
|
||||
- Сохраняются ли конец записи, пунктуация, числа и названия продуктов после long-form разбиения.
|
||||
- Дают ли Nemotron word boosting и Qwen `context` воспроизводимый выигрыш на терминах без ухудшения обычной русской речи.
|
||||
- Проходит ли результат обязательный контракт пословных таймкодов на всей записи. Для Nemotron путь уже существует в NeMo-Speech.cpp; для Qwen его практичность еще не подтверждена.
|
||||
|
||||
Подробный корпус, команды, режимы и критерии приемки в эту записку не входят. Если сравнительный эксперимент будет принят в работу, для него понадобится отдельная benchmark spec.
|
||||
@@ -0,0 +1,442 @@
|
||||
# Карта end-to-end архитектур CPU-диаризации
|
||||
|
||||
**Дата:** 2026-09-01
|
||||
|
||||
**Статус:** выбран один кандидат для свежего минимального A/B - Streaming
|
||||
Sortformer 4spk v2 Q8_0 через закрепленный NeMo-Speech.cpp. Это единственная
|
||||
ветвь, где полная замена каскада опирается на готовые Q8_0 веса и закрепляемый
|
||||
локальный CPU runtime. Production-профиль, CLI, конфигурационный контракт и
|
||||
модель по умолчанию не меняются.
|
||||
|
||||
Документ сохраняет обезличенные агрегаты задач #41 и #42. Старые закрытые
|
||||
хранилища временно остаются архивом только для чтения до завершения и приемки
|
||||
задачи #46. Ни стенд, ни сравнение в задаче #46 не должны получать к ним доступ:
|
||||
новая измерительная основа создается заново. После приемки нового результата
|
||||
архив можно удалить; тогда его данные нельзя будет получить повторно без нового
|
||||
вычисления. Здесь нет локальных путей, идентификаторов записей, текста,
|
||||
временных интервалов, manifest-содержимого или сырых строк результата.
|
||||
Непрозрачные SHA-256 уже опубликованных результатов оставлены как устойчивые
|
||||
ссылки на исторические байты. Они не создают будущую зависимость от этих байтов.
|
||||
|
||||
## Источники реестра
|
||||
|
||||
- [Intel-отчет](../benchmarks/2026-08-31-cpu-diarization-intel.md);
|
||||
- [Ryzen-отчет](../benchmarks/2026-08-31-cpu-diarization-ryzen.md);
|
||||
- [спецификация прежнего эксперимента](../specs/2026-08-30-cpu-diarization-experiment.md);
|
||||
- [ADR-007](../adr/007-word-level-speaker-diarization.md).
|
||||
|
||||
## Как читать реестр
|
||||
|
||||
`Ускорение` ниже означает сокращение wall time:
|
||||
|
||||
`100 x (wall_baseline - wall_candidate) / wall_baseline`.
|
||||
|
||||
Отрицательное значение означает замедление. Intel W/M/Q/E - одиночные
|
||||
разведочные прогоны трех 300-секундных фрагментов, если явно не указана соседняя
|
||||
пара. Ryzen-строки - принятые соседние A/B. ASR, загрузка моделей и декодирование
|
||||
не входят в wall.
|
||||
|
||||
Обязательный автоматический gate: `3 / 2 / 2` голосовых кластера, неизменный
|
||||
текст, повторяемые параметры/метки/финализация, конечные эмбеддинги и отсутствие
|
||||
потерянных или продублированных входов. `51 / 13 / 9` слов без говорящего и
|
||||
`mapped speaker purity = 0,76655 / 0,78742` - диагностика совпадающего baseline,
|
||||
а не ручная истина. Рост диагностического показателя требует прослушивания, но
|
||||
сам по себе не превращает вариант в проигравший.
|
||||
|
||||
## Переносимые рецепты
|
||||
|
||||
Все Intel-рецепты используют четыре физических ядра; Ryzen-рецепты - восемь.
|
||||
Везде `inter_op_threads=1`, batch 1, если строка не говорит обратного.
|
||||
Код измерительного стенда закреплен source commit
|
||||
`6345cb6449d906767b6ed33e695f2ca4eeaf3008`; sherpa-onnx 1.13.6 - upstream
|
||||
commit `1cb484af9b1ab05cc36f89e03dd7d76322f2e911` плюс опубликованный ниже патч.
|
||||
Последовательные W0-рецепты используют CPU provider для segmentation и
|
||||
embeddings, `debug=True` для обеих конфигураций,
|
||||
`min_duration_on=0.3` и `min_duration_off=0.5`. Debug-режим входит в
|
||||
измерительный рецепт: из его журнала получены native stage times.
|
||||
|
||||
| Recipe ID | Точный переносимый рецепт |
|
||||
|---|---|
|
||||
| `I-B135` | `sherpa-onnx/core 1.13.5`; Pyannote Segmentation 3.0 FP32; WeSpeaker ResNet34 LM FP32; `window_shift_ratio=0.1`; последовательный инференс; один worker/session; `intra_op=4`; `FastClustering`, auto-N, threshold `0.89`. |
|
||||
| `I-B136` / `I-W0` / `I-M0` / `I-Q0` / `I-E0` / `I-C0` | Тот же профиль, но `sherpa-onnx/core 1.13.6`; это совпадающий Intel baseline. |
|
||||
| `I-W(r)` | `I-B136`, меняется только `window_shift_ratio=r`. |
|
||||
| `I-M2` / `I-C1` | `I-B136`, но CAMPPlus вместо WeSpeaker; NME автоматически выбрал `p=111/54/104`, затем N=`3/2/2` и `FastClustering(N)`; sequential. |
|
||||
| `I-M3` | `I-B136`, но TitaNet small вместо WeSpeaker; NME автоматически выбрал `p=90/65/110`, затем N=`3/2/2` и `FastClustering(N)`; sequential. |
|
||||
| `I-Q1` | `I-B136`, но WeSpeaker S8S8 QDQ вместо FP32; sequential. |
|
||||
| `I-E1` / `I-C2` | `I-B136`, но два outer worker используют одну shared ONNX Runtime session и общий `intra_op=4`. |
|
||||
| `I-E2` | `I-B136`, но четыре outer worker, одна shared session, `intra_op=1`. |
|
||||
| `I-E3` | `I-B136`, но четыре outer worker и четыре отдельные session, у каждой `intra_op=1`; отрицательный контроль. |
|
||||
| `R-B` | `sherpa-onnx/core 1.13.6`; Pyannote FP32; WeSpeaker FP32; shift `0.1`; `FastClustering(auto, 0.89)`; sequential; один worker/session; `intra_op=8`. |
|
||||
| `R-C2` | `R-B`, но два outer worker, одна shared session, общий `intra_op=8`. |
|
||||
|
||||
Закрепленные SHA-256 моделей: Pyannote FP32
|
||||
`220ad67ca923bef2fa91f2390c786097bf305bceb5e261d4af67b38e938e1079`,
|
||||
WeSpeaker FP32
|
||||
`e9848563da86f263117134dfd7ad63c92355b37de492b55e325400c9d9c39012`,
|
||||
WeSpeaker QDQ
|
||||
`9a3f8ae64d1083b64a6276f1e1138b17b7511a9a8d731b632945efc84098588b`,
|
||||
CAMPPlus
|
||||
`aa3cfc16963a10586a9393f5035d6d6b57e98d358b347f80c2a30bf4f00ceba2`,
|
||||
TitaNet small
|
||||
`ad4a1802485d8b34c722d2a9d04249662f2ece5d28a7a039063ca22f515a789e`.
|
||||
Экспериментальные `Prepare/Finalize` и shared worker требовали патч
|
||||
`5f12df858cd525c8a0cfe8ff95b647c3b0e915a13d45621acc4ac42660014c49`:
|
||||
API не было ни в 1.13.6, ни в проверенном upstream master. Ryzen-wheel был
|
||||
пересобран из того же upstream commit и патча из-за несовместимости GLIBC;
|
||||
его опубликованный SHA-256 -
|
||||
`baaaa2532606f1e1411e5c84655afa70107ad4b1676420953f5e67233c46a484`.
|
||||
|
||||
## Реестр: baselines и проверки механизмов
|
||||
|
||||
| Stable ID | Recipe / измененная ось | Машина / стадия | Wall / ускорение / RSS | Обязательное качество | Решение и точная причина | Публичное доказательство |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `41-V135` | `I-B135`; version baseline | Intel / version baseline | `190,104 с`; baseline; `365,7 МиБ` в соответствующем сыром агрегате | `3/2/2`, текст и диагностика baseline | Принят как контроль версии; одиночный запуск не доказывает причинное ускорение/замедление версии | `docs/benchmarks/2026-08-31-cpu-diarization-intel.md`, "Зачем понадобился 1.13.6" |
|
||||
| `41-V136` | `I-B136`; меняется только runtime 1.13.5->1.13.6 | Intel / version baseline | `157,892 с`; `+16,94%` к `41-V135`; `356,7 МиБ` | `3/2/2`; канонические интервалы, назначения слов и диагностика совпали | Baseline 1.13.6 принят; разница времени диагностическая, без повторов | тот же раздел; SHA `eb7e3d508836e1eefd679a851271eaa6823d688fef39d2ae03978e7fa7006bba` |
|
||||
| `41-S-WINDOW` | 1.13.6 принимает shift `0.10/0.15/0.20/0.25/0.50`; `0.10` сохраняет поведение | Intel / smoke | end-to-end wall/RSS: not measured | Smoke пройден | Допустить W0-W4 | `docs/specs/2026-08-30-cpu-diarization-experiment.md`, этап 0 |
|
||||
| `41-S-PF` | Локальный `Prepare -> cluster(copy) -> Finalize` против штатного `process()` | Intel / smoke | wall/ускорение/RSS: not measured | Строки, метки и интервалы совпали; пропусков/дублей нет | Использовать патч в стенде | Intel-отчет, этап 0 |
|
||||
| `41-S-MANIFEST` | Строгая schema v3 и стабильные ID ячеек | Intel / smoke | не применимо | Валидация капсулы: 199 файлов, QDQ доступен, предупреждений нет | Использовать; это механизм воспроизводимости, не измерительная ячейка | Ryzen-отчет, handoff |
|
||||
| `41-S-CEIG` | CAMPPlus prepared-матрицы; eigengap | Intel / counter smoke | counter wall по трем матрицам `0,107 / 1,575 / 0,067 с`; end-to-end ускорение/RSS: not measured | N=`6/1/7`, не `3/2/2` | Исключить счетчик для CAMPPlus: обязательный count gate провален | Новый aggregate задачи #45 из frozen raw; previous result SHA not published |
|
||||
| `41-S-CNME` | Те же CAMPPlus-матрицы; NME + `FastClustering(N)` | Intel / counter smoke | counter wall `3,536 / 1,488 / 1,148 с`; end-to-end ускорение/RSS: not measured | N=`3/2/2`, повторяемые метки, 0 потерянных строк | Выбрать для M2/C1 | Intel-отчет, индекс доказательств |
|
||||
| `41-S-TEIG` | TitaNet prepared-матрицы; eigengap | Intel / counter smoke | counter wall `0,126 / 0,073 / 0,101 с`; end-to-end ускорение/RSS: not measured | N=`7/2/4`, не `3/2/2` | Исключить счетчик для TitaNet: count gate провален | Новый aggregate задачи #45 из frozen raw; previous result SHA not published |
|
||||
| `41-S-TNME` | Те же TitaNet-матрицы; NME + `FastClustering(N)` | Intel / counter smoke | counter wall `1,084 / 0,780 / 1,112 с`; end-to-end ускорение/RSS: not measured | N=`3/2/2`, повторяемые метки, 0 потерянных строк | Выбрать для M3 | Intel-отчет, индекс доказательств |
|
||||
| `41-S-QDQ` | WeSpeaker S8S8 QDQ, 48 независимых calibration-интервалов | Intel / artifact smoke | wall/ускорение/RSS: not measured | interface/opset/metadata, checker, dynamic shapes и загрузка sherpa пройдены | Допустить Q1; техническая корректность не означает качество/скорость | Intel-отчет, этап 0; model SHA выше |
|
||||
| `41-S-SHARED` | Один и два worker, одна session, одинаковые prepared-входы | Intel / worker smoke | wall/ускорение/RSS: not measured | строки, метки и интервалы совпали; гонок/NaN/потерь нет | Допустить E1/E2 | Ryzen-отчет, этап 0 |
|
||||
| `41-S-TB1` | TitaNet batch 1 | Intel / embedding smoke | embedding-only `75,923 с` суммарно; ускорение/RSS: not measured | штатные эмбеддинги воспроизведены, max abs drift <= `3,4e-6` | Контроль batch | Ryzen-отчет, этап 3; batch SHA `f12bfdb5cadf80c62871c0725eac744a99825cb5d06b70fcf11e4e538ff6df72` |
|
||||
| `41-S-TB4` | TitaNet batch 4 | Intel / embedding smoke | embedding-only `98,596 с`, на `29,86%` медленнее batch 1; RSS: not measured | `3/2/2`, но метки всех трех матриц изменились; drift `0,277-0,317` | Остановить до CPU-ячейки: медленнее и нарушена идентичность меток | Ryzen-отчет, этап 3; тот же batch SHA |
|
||||
|
||||
Counter wall в строках `41-S-CEIG` и `41-S-TEIG` впервые опубликован в задаче
|
||||
#45 как безопасный aggregate frozen raw evidence. Прежний result SHA не был
|
||||
опубликован; после удаления хранилища эти числа нельзя повторно сверить с
|
||||
исходными байтами.
|
||||
|
||||
Примечание к `41-V135`: публичный Intel-индекс печатает SHA
|
||||
`c53c1f970ee4db094779bc32b42f978c37c2cb74d814504ccfac87f09e802ddd`,
|
||||
но этот хеш связывает другой сырой агрегат (`148,806 с`), а не опубликованные
|
||||
`190,104 с`. Сырой результат с `190,104 с` имеет неопубликованный хеш. Поэтому
|
||||
для этой строки устойчивым публичным доказательством остается только якорь
|
||||
публичного отчета; после удаления хранилища байтовая проверка числа невозможна.
|
||||
|
||||
## Реестр: Intel W/M/Q/E/C
|
||||
|
||||
| Stable ID | Recipe / измененная ось | Wall / ускорение / RSS | Обязательное качество и диагностика | Решение и точная причина | Публичное доказательство |
|
||||
|---|---|---|---|---|---|
|
||||
| `41-W0` | `I-W(0.10)`, совпадающий baseline | `148,220 с`; baseline; `361,2 МиБ` в том же raw result | pass `3/2/2`; слова без говорящего `51/13/9` | Принять shift `0.10` | SHA `32d513cd0e81fdca0e37484f86b5c4312d3573608493972a282302dd891cdf0e` |
|
||||
| `41-W1` | `I-W(0.15)`; меняется только shift | `100,799 с`; `+31,99%`; `362,6 МиБ` | fail `2/2/2`; `50/17/9` | Исключить: потерян кластер на первом фрагменте | SHA `be8935b5a81e2aadaeb38540e615c0cd3ae5ac31d6e0949856fd7d16d31a78ea` |
|
||||
| `41-W2` | `I-W(0.20)` | `76,127 с`; `+48,64%`; `363,6 МиБ` | fail `2/2/2`; `52/13/9` | Исключить: потерян кластер | SHA `f95433d516772d40c4071da0d0745b981314ee7a7063fb4a43518e94bc17848e` |
|
||||
| `41-W3` | `I-W(0.25)` | `61,369 с`; `+58,60%`; `358,4 МиБ` | fail `2/2/2`; `59/16/11` | Исключить: потерян кластер | SHA `14cac4f1d8cd38ed5b4a20189438d2b1e109ba267c5feaa36f0928fbec848580` |
|
||||
| `41-W4` | `I-W(0.50)` | `31,291 с`; `+78,89%`; `360,7 МиБ` | fail `1/2/2`; `0/19/10` | Исключить: потеряны два кластера суммарно относительно `3/2/2` | SHA `6fafefce00e90590ab2d834fb560413cd7780e20cbd1f631d9954ec8b97a540e` |
|
||||
| `41-M0` | `I-M0`; baseline модельной оси | Переиспользован `41-W0`: `148,220 с`; `361,2 МиБ`; отдельное ускорение: not measured | Качество baseline прошло | Точка сравнения; не новая гипотеза | Ryzen-отчет, этап 2; SHA W0 выше |
|
||||
| `41-M2` | `I-M2`; меняются embedding model и estimator N | `94,550 с`; `+36,21%`; `358,6 МиБ` | Обязательный gate пройден: `3/2/2`; `53/13/9` | Самая быстрая модельная ветвь; собрать C1, но диагностический рост требует слухового gate | SHA `3debc8ae5cf1bd13d806dbfe8e4cfa137d6705622a721f4a42f1a22c030ec277` |
|
||||
| `41-M3` | `I-M3`; меняются embedding model и estimator N | `107,205 с`; `+27,67%`; `511,9 МиБ` | Обязательный gate пройден: `3/2/2`; `52/13/9` | Не выбирать: медленнее и тяжелее M2; обязательный gate при этом пройден | SHA `531a842234e35e29f582ff8a51406fb9dd6211e017b3162d80cca21d4732d316` |
|
||||
| `41-Q0` | `I-Q0`; baseline quantization-оси | Переиспользован `41-W0`; отдельный raw aggregate: not published | Качество baseline прошло | Точка сравнения; не новая гипотеза | Старая спецификация, QDQ |
|
||||
| `41-Q1` | `I-Q1`; FP32->S8S8 QDQ | `177,537 с`; `-19,78%` (замедление); `367,7 МиБ` | `fail`: `2/2/2`; `51/13/8` | Исключить: медленнее и потерян кластер | Intel-отчет, этапы 2-3; result SHA not published |
|
||||
| `41-Q-U8-SKIP` | Условный U8U8; меняется формат квантизации; Intel / этап 3 | wall/ускорение/RSS: not measured | Качество: not measured | Ветка не открылась: S8S8 не достиг 10%-го порога продолжения и провалил обязательное качество | Старая спецификация, этап 3; result SHA not published |
|
||||
| `41-E0a` | `I-E0`; companion baseline для E1 | Отдельные wall/ускорение/RSS: not published; отдельный result blob: not published; сравнительный W0=`148,220 с` | Качество baseline прошло | Служебная baseline-ячейка; самостоятельного решения нет | Intel-отчет, таблица ID ячеек |
|
||||
| `41-E1` | `I-E1`; topology 1 worker->2 shared workers | `139,219 с`; `+6,07%`; `398,6 МиБ` | Качество прошло: `3/2/2`; метки/интервалы/диагностика совпали | Ниже Intel-порога продолжения, но допущен как заранее оговоренная ISA-гипотеза для собственного Ryzen A/B | SHA `f5ac41d29eeff1f7cfbb93497a0025aef40de0838e933e0f9baed30a236d6b85` |
|
||||
| `41-E0b` | `I-E0`; companion baseline для E2 | Отдельные wall/ускорение/RSS: not published; сравнительный W0=`148,220 с` | Качество baseline прошло | Служебная baseline-ячейка | Intel-отчет, таблица ID ячеек |
|
||||
| `41-E2` | `I-E2`; 4 shared workers, `intra_op=1` | `152,167 с`; `-2,66%`; `551,2 МиБ` | pass `3/2/2`, точное совпадение результата | Исключить: медленнее и больше RSS | SHA `f2664d354ede1e4bf2c42b974513cdc845ff601c73f30ebae51456764fd8dcdc` |
|
||||
| `41-E0c` | `I-E0`; companion baseline для E3 | Отдельные wall/ускорение/RSS: not published; сравнительный W0=`148,220 с` | Качество baseline прошло | Служебная baseline-ячейка | Intel-отчет, таблица ID ячеек |
|
||||
| `41-E3` | `I-E3`; shared->4 separate session | `159,038 с` составная оценка; `-7,30%`; `732,3 МиБ` process-wide | `pass`: `3/2/2`; метки совпали | Исключить: отрицательный контроль подтвердил рост времени и памяти; wall не равноправен штатному `process()` | Intel-отчет, этапы 2-3; result SHA not published |
|
||||
| `41-C0` | `I-C0`; рабочий shift + baseline stack | wall/ускорение/RSS: not measured | по определению совпадает с прошедшим W0 | Явно пропущена как дубликат baseline | Intel-отчет, этап 1/4 |
|
||||
| `41-C1` | `I-C1`; финальная проверка сильнейшей Intel-ветви | `98,347 с`; `+33,65%` к одиночному W0; `399,5 МиБ` | Обязательный gate пройден: `3/2/2`, purity `0,80137/0,91079`; слова `53/13/9` (`+2` на первом фрагменте) | **Неразрешенный слуховой gate: ни победитель, ни проигравший.** Не передавать до прослушивания подготовленных мест | SHA `4f96e6f7363e0edb79d4034a07f4d3b6b0003a820fcf6bceff5426bb3c2caff4` |
|
||||
| `41-W0C2-SWAP` | `I-B136`; первая попытка guarded baseline; Intel / финальная пара | wall/ускорение/RSS: not published | Качество: not published | Отклонить: runtime guard обнаружил swap-in | Intel-отчет, раздел "Условия"; result SHA not published |
|
||||
| `41-W0C2` | `I-B136`; соседний guarded baseline | `146,978 с`; baseline; `376,4 МиБ` | Качество прошло; диагностика baseline | Допустимая база сравнения только для `41-C2` | SHA `e572dd057ec87514aca4617465c48ce361677ae19279efcadbbde404afc85b5d` |
|
||||
| `41-C2` | `I-C2`; baseline->2 shared workers | `140,632 с`; `+4,32%`; `406,0 МиБ` | Обязательная и диагностическая проверка точно совпала с `41-W0C2` | Intel-порог не достигнут; передать как работоспособную аппаратную ISA-гипотезу по явному правилу спецификации | SHA `d687074cc582e2ac379ed3375362f9480778feca02c7492a4c4be4fcc1a4f15a` |
|
||||
|
||||
`M1` не был ячейкой согласованной матрицы: нумерация spec - M0, M2, M3.
|
||||
|
||||
В публичной сводке для W0/M0 указан RSS `362,6 МиБ`, однако опубликованный
|
||||
SHA W0 связывает wall `148,220 с` с RSS `361,2 МиБ`. В реестре выше сохранена
|
||||
согласованная пара из одного raw result; публичные `362,6 МиБ` нельзя привязать
|
||||
к тому же опубликованному SHA.
|
||||
|
||||
## Реестр: Ryzen, этапы 5-6
|
||||
|
||||
Для каждой A/B-строки recipe baseline - `R-B`, candidate - `R-C2`; меняется
|
||||
только topology embedding-инференса. Все ячейки прошли обязательный gate и
|
||||
сохранили baseline-диагностику (`3/2/2`, `51/13/9`, та же purity, те же числа
|
||||
окон/embedding jobs, без потерь или дублей).
|
||||
|
||||
| Stable ID | Машина / стадия / порядок | Wall baseline -> candidate / ускорение | RSS baseline -> candidate | Решение и точная причина | Published result SHA-256 |
|
||||
|---|---|---|---|---|---|
|
||||
| `42-W0` | Ryzen / matching baseline | `110,998 с`; candidate: not measured | `370,4 МиБ`; candidate: not measured | Baseline принят | `f50684784392cacc2d99c8aee51d01f0a67dfee3da6ea3ba5f3db749a3d5287a` |
|
||||
| `42-E1-AB` | Ryzen / isolated mechanism / W0->E1 | `111,557 -> 84,723 с`; `24,05%` | `367,3 -> 412,2 МиБ` | Механизм прошел качество, память и 10%-й порог продолжения | `24d5edae117e0619e432ef3f3019e8c66cf3516b87b80b64b8591390db25bf48` |
|
||||
| `42-C2-P` | Ryzen / primary / C2->W0 | `111,423 -> 85,909 с`; `22,90%` | `369,6 -> 410,3 МиБ` | Прошел primary-порог; допустить пять циклов | `142db6d06efc8d558b38e18f47f47e3e35e7df00751da2583339f99add8d75c4` |
|
||||
| `42-C2-F1` | Ryzen / final 1 / W0->C2 | `111,354 -> 83,529 с`; `24,99%` | `369,2 -> 413,4 МиБ` | Принятый цикл; качество pass | `4b5095c9e64ff0ef75bbc58516c662bedbbdb0bedf131f66cc1031c9a0ad8c7f` |
|
||||
| `42-C2-F2` | Ryzen / final 2 / C2->W0 | `108,899 -> 83,907 с`; `22,95%` | `373,0 -> 418,1 МиБ` | Принятый цикл; качество pass | `256a17b2d08510aad03d43f5c626c1a4269c9a25271ce492dc3305c4afca1888` |
|
||||
| `42-C2-F3` | Ryzen / final 3 / W0->C2 | `110,928 -> 84,968 с`; `23,40%` | `373,8 -> 410,9 МиБ` | Принятый цикл; качество pass | `b5d37c7a8b05b065c61bc9d5f1c0ab639a80a821730e505179388d90c4239caa` |
|
||||
| `42-C2-F4` | Ryzen / final 4 / C2->W0 | `114,985 -> 83,342 с`; `27,52%` | `376,6 -> 411,1 МиБ` | Принятый цикл; качество pass | `639cc9f13511a59c7240c6fb6ec6bf1c31ec26350330e3ca0c64fb721b768736` |
|
||||
| `42-C2-F5` | Ryzen / final 5 / W0->C2 | `113,233 -> 86,548 с`; `23,57%` | `358,6 -> 413,4 МиБ` | Принятый цикл; качество pass | `4ff2863a93fc22a037de5c292391b6b46de1b539cf397026b349ea5134cf1051` |
|
||||
| `42-STAGE6-SKIP` | Ryzen / этап 6 / `R-B` и `R-C2` на трех полных записях | wall/ускорение: not measured | RSS: not measured | Этап пропущен: медиана финальных циклов `23,57%` ниже порога `30%`; качество: not measured | Ryzen-отчет, "Итог"; result SHA not published |
|
||||
|
||||
Итог C2: медиана сокращения `23,57%`, MAD `0,62 п. п.`; до порога `go`
|
||||
`30%` не хватило `6,43 п. п.`. Качество и память прошли, скорость - нет;
|
||||
решение всей воронки `new map`. Stage 6 (полные записи и пятичастная слуховая
|
||||
приемка) не запускался, потому что победителя нет.
|
||||
|
||||
## Измеренное разложение текущего Ryzen baseline
|
||||
|
||||
### Семантика полей
|
||||
|
||||
В непатченном последовательном baseline outer `wall_seconds` измеряется вокруг
|
||||
одного `diarizer.process(samples)`. Native debug отдельно дает
|
||||
`segmentation_seconds`, `embedding_seconds`, `clustering_seconds` и
|
||||
`engine_total_seconds`. Поэтому для каждого из трех фрагментов, а затем для их
|
||||
суммы, допустимо вычислить:
|
||||
|
||||
`orchestration = measured outer wall - native engine total`.
|
||||
|
||||
Это остаток границы Python/native плюс progress callback и измерительный
|
||||
обвес; схема не позволяет разложить его дальше и не доказывает, что весь остаток
|
||||
принадлежит production-коду. Native stage и native total напечатаны с точностью
|
||||
до `0,001 с`; таблица округляет итоговые суммы до `0,001 с`. Разность использует
|
||||
сохраненные числа, до отображения не округляется.
|
||||
|
||||
У патченного shared-session runner `engine_total_seconds` намеренно записан
|
||||
равным outer wall всего `Prepare + counter + clustering + Finalize`. Это не
|
||||
отдельный native total. Поэтому для E1/C2 нельзя честно вычислить orchestration
|
||||
как `outer - native`: соответствующая величина **недоступна**, а ноль был бы
|
||||
артефактом схемы.
|
||||
|
||||
### Сумма трех записей в каждой принятой baseline-ячейке
|
||||
|
||||
| Baseline ID | Outer wall, с | Segmentation, с | Embeddings, с | Clustering, с | Native engine total, с | Orchestration, с |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| `42-W0` | 110,998 | 18,233 | 82,172 | 0,015 | 100,444 | 10,554 |
|
||||
| `42-E1-AB/W0` | 111,557 | 17,888 | 82,952 | 0,015 | 100,871 | 10,686 |
|
||||
| `42-C2-P/W0` | 111,423 | 19,685 | 83,635 | 0,014 | 103,357 | 8,066 |
|
||||
| `42-C2-F1/W0` | 111,354 | 18,614 | 84,596 | 0,015 | 103,240 | 8,114 |
|
||||
| `42-C2-F2/W0` | 108,899 | 21,071 | 79,911 | 0,012 | 101,010 | 7,889 |
|
||||
| `42-C2-F3/W0` | 110,928 | 21,204 | 81,722 | 0,016 | 102,960 | 7,968 |
|
||||
| `42-C2-F4/W0` | 114,985 | 19,594 | 84,715 | 0,013 | 104,342 | 10,643 |
|
||||
| `42-C2-F5/W0` | 113,233 | 21,625 | 82,884 | 0,013 | 104,540 | 8,693 |
|
||||
| **Суммарно: 8 ячеек / 24 записи** | **893,377** | **157,914** | **662,587** | **0,113** | **820,764** | **72,613** |
|
||||
|
||||
Stage-суммы отличаются от native total pooled на `0,150 с` (`0,017%` outer
|
||||
wall): это ожидаемый остаток тысячного округления native debug, а не оцененная
|
||||
пятая стадия.
|
||||
|
||||
Медианы по восьми matching baselines, рассчитанные из показанных значений без
|
||||
промежуточного округления: outer `111,388 с`, segmentation `19,6395 с`,
|
||||
embeddings `82,918 с`, clustering `0,0145 с`, native total
|
||||
`103,100 с`, orchestration `8,404 с`.
|
||||
|
||||
Наблюдаемый spread (max-min, процент относительно min):
|
||||
|
||||
| Величина | Диапазон, с | Spread |
|
||||
|---|---:|---:|
|
||||
| Outer wall | `108,899-114,985` | `6,087 с` / `5,59%` |
|
||||
| Segmentation | `17,888-21,625` | `3,737 с` / `20,89%` |
|
||||
| Embeddings | `79,911-84,715` | `4,804 с` / `6,01%` |
|
||||
| Clustering | `0,012-0,016` | `0,004 с`; относительный процент неинформативен при гранулярности `0,001 с` |
|
||||
| Native total | `100,444-104,540` | `4,096 с` / `4,08%` |
|
||||
| Orchestration residual | `7,889-10,686` | `2,797 с` / `35,45%` |
|
||||
|
||||
Это объясняет, почему Intel-разница W0/C2 `4,32%` неотличима от наблюдавшегося
|
||||
там разброса, а Ryzen-финал опирался на парные циклы, а не на один baseline.
|
||||
|
||||
## Доли, проверенные механизмы и Amdahl ceiling
|
||||
|
||||
Доли ниже - pooled stage time / pooled outer wall восьми matching Ryzen
|
||||
baseline. Amdahl ceiling предполагает невозможное идеальное удаление ровно
|
||||
одного компонента при неизменности остальных:
|
||||
|
||||
`max speedup = 1 / (1 - share)`.
|
||||
|
||||
| Компонент | Измеренная доля current Ryzen wall | Уже проверенные сокращения | Что осталось после эксперимента | Теоретический ceiling |
|
||||
|---|---:|---|---|---:|
|
||||
| Segmentation | `17,676%` | W1-W4 уменьшали число окон `291->195/146/117/59` на каждом фрагменте и вместе число embedding jobs; wall падал до `31,291 с`, но уже W1 потерял обязательный кластер | Без новой segmentation-модели/логики окон дальнейшее увеличение shift закрыто quality gate | максимум `1,2147x` (`17,676%` сокращения wall) при полном устранении стадии |
|
||||
| Embeddings | `74,167%` | CAMPPlus+NME (`36,21%` Intel, слуховой gate C1 открыт), TitaNet+NME (`27,67%`, тяжелее M2), QDQ (медленнее и fail), shared session (Ryzen median `23,57%`), 4 workers/separate sessions (хуже), TitaNet batch 4 (медленнее и другие метки) | Это доминирующая измеренная стадия; локальные замены прежнего каскада не дали принятого 30%-победителя | максимум `3,8709x` (`74,167%` wall) при полном устранении стадии |
|
||||
| Clustering | `0,013%` | Eigengap быстро, но неверный N; NME дал правильный N для CAMPPlus/TitaNet, однако его отдельный counter wall измеряется секундами и не является baseline native clustering time | Ускорять baseline `FastClustering` бессмысленно по Amdahl; менять алгоритм можно только ради качества/архитектуры | максимум `1,00013x` (`0,013%` wall) |
|
||||
| Orchestration residual | `8,128%` | `Prepare/Finalize` и shared worker прошли correctness smoke; отдельного эксперимента на сокращение остатка не было | Нельзя отделить project orchestration от callback/RSS/harness; candidate-side доля недоступна из текущей схемы | максимум `1,0885x` (`8,128%` wall) при полном устранении остатка |
|
||||
|
||||
Сумма четырех долей `99,983%`; недостающие `0,017%` - только эффект
|
||||
округления native stage logs. Нельзя складывать наблюденные ускорения разных
|
||||
ячеек как независимые: W-ветвь одновременно сокращала segmentation и число
|
||||
embedding jobs, а shared-session меняла исполнение embedding внутри патченного
|
||||
runner.
|
||||
|
||||
## Открытые gates и неизмеренные факты
|
||||
|
||||
1. **C1 остается неразрешенным слуховым gate - ни победитель, ни проигравший.**
|
||||
Обязательная автоматическая проверка прошла, скорость на Intel превысила
|
||||
30%, purity выросла, но два
|
||||
дополнительных слова без говорящего требуют человеческого прослушивания.
|
||||
Автоматические метрики не разрешают этот выбор.
|
||||
2. Полные записи не измерялись ни для C1, ни для C2. C1 не дошла до Ryzen, C2
|
||||
не прошла финальный порог скорости.
|
||||
3. Ryzen C1 wall/RSS/quality неизвестны полностью; переносить Intel `33,65%` на
|
||||
другую ISA нельзя.
|
||||
4. Для shared-session E1/C2 нет schema-compatible native total и, следовательно,
|
||||
нет candidate-side orchestration share. Доступные segmentation/embedding/
|
||||
clustering поля не закрывают остаток `counter + Finalize + Python` отдельными
|
||||
сопоставимыми стадиями.
|
||||
5. Orchestration baseline - измеренный residual, но его production/harness
|
||||
состав не измерен.
|
||||
6. Температура Ryzen во время ячеек недоступна. Внешний power/throttle
|
||||
guard прошел, но не заменяет сенсор внутри WSL.
|
||||
7. Intel W/M/Q/E разведка - по одному прогону, ранние ячейки не чередовались;
|
||||
их проценты не являются оценкой дисперсии.
|
||||
8. End-to-end архитектуры не входили в матрицу. Измерены только локальные
|
||||
изменения прежнего каскада.
|
||||
9. `M1` отсутствует по дизайну, C0 пропущена как точный baseline-дубликат,
|
||||
stage 6 пропущен по правилу "нет победителя"; это известные пропуски, а не
|
||||
потерянные результаты.
|
||||
10. Отдельные агрегаты Intel companion-ячеек M0/Q0/E0 не опубликованы. Их можно
|
||||
использовать только как recipe-alias W0, а не выдавать за новые замеры.
|
||||
|
||||
## Что станет невосстановимым после удаления замороженных хранилищ
|
||||
|
||||
Удаление обоих хранилищ отложено до завершения и приемки задачи #46. Пока они
|
||||
сохраняются, их можно использовать только для ретроспективного аудита старых
|
||||
результатов, но не как вход или базу сравнения нового опыта. После удаления
|
||||
останутся публичные рецепты, агрегаты, решения и непрозрачные хеши, но сами хеши
|
||||
уже нельзя будет повторно сверить с исходными байтами.
|
||||
|
||||
Без нового model run или повторной подготовки будут потеряны:
|
||||
|
||||
- все raw per-recording outer/stage/CPU/RSS значения за пределами опубликованных
|
||||
агрегатов, включая точное восьмиклеточное Ryzen-разложение;
|
||||
- сырые интервалы разметки говорящих, назначения слов, диагностические records,
|
||||
ASR-sidecars и опорные материалы;
|
||||
- prepared embedding-матрицы, row mappings, промежуточные результаты счетчиков,
|
||||
worker requests и native logs, которыми проверялись строки/метки/финализация;
|
||||
- runtime-guard свидетельства и полное состояние планировщика/manifest, включая
|
||||
непубличные идентификаторы ячеек и порядок внутренних повторов;
|
||||
- подготовленные C1 listening excerpts и plan. Пока архив существует, они
|
||||
доступны только для отдельного аудита C1 и не входят в задачу #46. После
|
||||
удаления для будущего решения C1 придется заново построить минимальный
|
||||
baseline/C1 diff и новые excerpts из исходных записей;
|
||||
- calibration provenance и байты QDQ-графа, локальные wheel/patch artifacts,
|
||||
если они отдельно не сохранены; опубликованный SHA доказывает идентичность
|
||||
только при наличии байтов;
|
||||
- возможность расследовать расхождения публичного 1.13.5 hash/wall и W0 RSS по
|
||||
исходным файлам.
|
||||
|
||||
Исходные записи, по условию задачи #45, останутся лишь удобными входами для нового
|
||||
минимального среза. Они не восстанавливают старые sidecars, prepared matrices,
|
||||
слуховой plan, environment state или старые timing samples без повторного
|
||||
вычисления.
|
||||
|
||||
## Граница выводов
|
||||
|
||||
Исторические строки выше описывают прежний каскад и прежние контрольные
|
||||
фрагменты. Они отвечают на вопрос, какие локальные изменения уже проверены.
|
||||
Новый эксперимент создает из сохраненных исходных записей собственные
|
||||
фрагменты, ASR-sidecar, опорную разметку, baseline-диагностику и закрытое
|
||||
хранилище результата. Числа `3 / 2 / 2`, `51 / 13 / 9` и прежняя purity
|
||||
остаются характеристиками старых контролей. В новый gate переходят правила
|
||||
точного совпадения с заново проверенным числом голосовых кластеров,
|
||||
неизменности текста и безопасной памяти.
|
||||
|
||||
C1 после удаления старого слухового материала остается исторически
|
||||
неразрешенной. Для ее повторного открытия требуется свежая минимальная пара
|
||||
W0/C1 и заново подготовленный материал для прослушивания. Карта и следующий
|
||||
эксперимент эту работу не выполняют.
|
||||
|
||||
## Класс архитектур и формула полной замены
|
||||
|
||||
Текущий путь строит разметку говорящих каскадом `segmentation -> embeddings ->
|
||||
clustering`. В sherpa-onnx 1.13.6 отдельный эмбеддинг вычисляется для каждой
|
||||
пары локального говорящего и окна; это подтверждает
|
||||
[upstream-реализация](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.6/sherpa-onnx/csrc/offline-speaker-diarization-pyannote-impl.h).
|
||||
Суммарное разложение выше показывает, что локальные изменения embeddings имеют
|
||||
большой доступный бюджет, однако принятый 30%-й результат на Ryzen не получен.
|
||||
|
||||
Для сравнения архитектур используется одна формула:
|
||||
|
||||
```text
|
||||
B = S + E + C + O
|
||||
T_candidate = B - R + A
|
||||
speedup = (R - A) / B
|
||||
идеальный предел при A = 0: R / B
|
||||
```
|
||||
|
||||
`B` - measured outer wall текущего W0; `S`, `E` и `C` - измеренные native
|
||||
стадии; `O` - derived residual `outer wall - native engine total`; `R` - работа
|
||||
старого пути, которую удаляет гипотеза; `A` - измеряемая работа новой
|
||||
архитектуры. Для полной замены `R = B`, поэтому фактическое сокращение равно
|
||||
`1 - A / B`. Upstream не публикует переносимый CPU RTF для выбранной модели;
|
||||
числовой прогноз `A` отсутствует. Его определит свежий A/B.
|
||||
|
||||
## Реестр архитектурных гипотез
|
||||
|
||||
### A1. Streaming Sortformer v2 Q8_0 через NeMo-Speech.cpp - выбран
|
||||
|
||||
**Механизм и затронутые стадии.** NEST/Fast-Conformer encoder и следующий за
|
||||
ним Transformer выдают четыре sigmoid-потока активности. Arrival-Order Speaker
|
||||
Cache связывает говорящих между streaming-чанками; порядок меток задается
|
||||
первым появлением голоса. Модель возвращает `T x 4` вероятностей с шагом 80 мс
|
||||
и заменяет текущие segmentation, embeddings, подсчет/кластеризацию и
|
||||
финализацию. Архитектура описана в
|
||||
[карточке модели NVIDIA](https://huggingface.co/nvidia/diar_streaming_sortformer_4spk-v2)
|
||||
и [первичной статье](https://arxiv.org/abs/2507.18446).
|
||||
|
||||
**Поведение quality gate.** Потери W1-W4 возникли после прореживания
|
||||
10-секундных окон перед независимыми эмбеддингами. Sortformer оценивает
|
||||
перекрывающиеся активности общей сетью и не использует эту сетку или порог AHC.
|
||||
Ошибки могут перейти в границы 80-мс кадров, рождение канала и сохранение метки
|
||||
в speaker cache, поэтому точное число кластеров и слуховая проверка остаются
|
||||
обязательными.
|
||||
|
||||
**Предел и риск.** Полная замена дает идеальный предел 100% вызова, а
|
||||
фактическое сокращение `1 - A_sortformer / B`. Опубликованный RTF `0,002`
|
||||
измерен на RTX 6000 Ada, batch 1, и не является CPU-прогнозом
|
||||
([model card](https://huggingface.co/nvidia/diar_streaming_sortformer_4spk-v2#setting-up-streaming-configuration)).
|
||||
Модель имеет четыре выходных канала говорящих; NVIDIA предупреждает о
|
||||
деградации на пяти и более слышимых голосах, неанглийской речи, другом домене и
|
||||
очень длинных записях
|
||||
([technical limitations](https://huggingface.co/nvidia/diar_streaming_sortformer_4spk-v2#technical-limitations)).
|
||||
|
||||
**Самая дешевая фальсификация.** На Ryzen выполнить один свежий W0 и одну
|
||||
соседнюю пару W0/Sortformer на трех заново определенных пятиминутных
|
||||
фрагментах. Остановить ветвь при несовпадении с новыми ожидаемыми числами
|
||||
кластеров, нарушении текстовой инвариантности, OOM/swap/росте RSS без плато или
|
||||
ускорении меньше 10%. Точный контракт находится в
|
||||
[спецификации следующего эксперимента](../specs/2026-09-01-streaming-sortformer-cpu-experiment.md).
|
||||
|
||||
**Повторное использование.** Переносятся рецепт Ryzen W0, правила gate,
|
||||
каноническое сведение `Word` с `SpeakerInterval`, безопасные агрегаты и
|
||||
сохраненные исходные записи. Sidecar, опорная разметка, фрагменты и закрытое
|
||||
хранилище создаются заново. Prepared matrices, старые интервалы, патч и wheel
|
||||
не используются.
|
||||
|
||||
**Исполнимость.** NVIDIA публикует готовый Q8_0 GGUF, нативный Apache-2.0
|
||||
runtime с CPU backend и отдельным API диаризации
|
||||
([NeMo-Speech.cpp](https://github.com/NVIDIA/NeMo-Speech.cpp),
|
||||
[C ABI](https://github.com/NVIDIA/NeMo-Speech.cpp/blob/4f9676226f667d14608487df744f375db87127f8/include/nemo_speech/diar.h)).
|
||||
Веса имеют CC BY 4.0. Runtime и модель закреплены в следующей спецификации.
|
||||
Выбор разрешает только изолированный исследовательский стенд.
|
||||
|
||||
### A2-A6. Фальсифицируемые альтернативы
|
||||
|
||||
| ID | Механизм и отличие quality gate | Предел скорости | Главный риск | Самый дешевый тест | Что можно использовать повторно | Решение |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `A2` LS-EEND | Causal encoder, online attractors и retention полностью заменяют `B`; нет окон Pyannote, отдельных voice embeddings и AHC. [Официальный код](https://github.com/Audio-WestlakeU/FS-EEND), [статья](https://doi.org/10.1109/TASLPRO.2025.3597446). | `1 - A_ls-eend / B`; CPU `A` не опубликована. | Публичные checkpoints привязаны к 8 кГц и доменам; готовой ONNX/GGUF CPU-поставки нет; возможен дрейф attractor. | До контрольного аудио проверить официальный checkpoint, лицензию, CPU device и однозначный 16-кГц/resampling contract; затем синтетический 60-секундный smoke. | Свежие входы, sidecar и общие gate; prepared embeddings неприменимы. | Отложить: A1 проверяет полную streaming-замену с готовым CPU runtime дешевле. |
|
||||
| `A3` EEND-EDA | Self-attentive encoder и LSTM attractors выдают одновременные активности и собственный stop condition вместо AHC-N. [Статья](https://arxiv.org/abs/2005.09921), [официальная реализация](https://github.com/hitachi-speech/EEND). | `1 - A_eend-eda / B`; full-context attention может расти квадратично по длине. | Нет закрепленного универсального checkpoint и документированного CPU deployment; ошибка stop condition меняет N. | Сначала найти официальный checkpoint с лицензией, 16-кГц контрактом и CPU inference. До этого аудио не запускать. | Только свежие входы и общие gate. | Отложить до появления измеримого CPU artifact. |
|
||||
| `A4` EEND-VC / DiariZen | Local EEND совместно строит активности и vectors, constrained clustering связывает чанки; старые пороговые отказы не обязаны повториться. [EEND-VC](https://arxiv.org/abs/2105.09040), [DiariZen](https://github.com/BUTSpeechFIT/DiariZen), [pruning](https://arxiv.org/abs/2506.18623). | Удаляются `S+E+C`, добавляются WavLM/Conformer и новый clustering; CPU `A` не опубликована. | Даже pruned-модель требует 3,8 GMAC/s; официальный рецепт ориентирован на CUDA/Python 3.10; веса CC BY-NC 4.0. | В изолированной среде проверить импорт CPU и 30-секундный публичный sample; закрыть при несовместимости или RTF хуже W0. | Свежие входы и общие gate. | Отклонить для короткого списка: CPU path и лицензия не подходят следующему дешевому срезу. |
|
||||
| `A5` pyannote Community-1 | Powerset segmentation, WeSpeaker, VBx и exclusive diarization меняют границы и оценку N, сохраняя embedding cascade. [Исходник pipeline](https://github.com/pyannote/pyannote-audio/blob/main/src/pyannote/audio/pipelines/speaker_diarization.py), [model card](https://huggingface.co/pyannote/speaker-diarization-community-1), [powerset loss](https://arxiv.org/abs/2310.13025). | Гарантированно удаляется лишь старая clustering/часть сведения; ее measured ceiling близок к нулю. Batching остается неизмеренной реализационной гипотезой. | Тяжелый PyTorch runtime, предварительная авторизованная загрузка; dense segmentation и embedding cost сохраняются. | 60-секундный публичный CPU stage-profile с RSS; закрыть, если total не лучше W0 минимум на 10%. | Свежие слова, assignment-диагностика и категории прослушивания. | Отклонить: не атакует полную доминирующую работу каскада. |
|
||||
| `A6` NeMo MSDD | MarbleNet VAD, multi-scale TitaNet embeddings, clustering и neural decoder меняют все стадии и восстанавливают overlap. [Статья](https://arxiv.org/abs/2203.15974), [официальный код](https://github.com/NVIDIA-NeMo/Speech/tree/main/examples/speaker_tasks/diarization). | Знак ускорения неизвестен: VAD сокращает тишину, несколько embedding scales и decoder добавляют работу. | Штатный pretrained MSDD ориентирован на телефонию; готового meeting GGUF/ONNX CPU path нет; начальная ошибка N переходит в decoder. | До аудио потребовать подходящий meeting checkpoint и CPU contract; затем публичный 30-секундный stage smoke. | Свежие входы и общие gate. | Отклонить: добавляет embedding work без готового целевого CPU runtime. |
|
||||
|
||||
Реестр оставляет A2-A6 проверяемыми: для каждой ветви записан факт, который
|
||||
может изменить решение. Второй кандидат в короткий список не добавлен, потому
|
||||
что для него сначала пришлось бы создавать runtime или выбирать новые веса.
|
||||
|
||||
## Решение и владение соседними задачами
|
||||
|
||||
Следующее измерение проверяет ровно A1. Причины выбора: полная замена старого
|
||||
вызова, готовые Q8_0 веса, закрепляемый нативный CPU runtime и standalone
|
||||
разметка говорящих без изменения ASR. Контракт исполняет
|
||||
[задача #46](https://git.dementev.space/ddmitry/local-transcriber/issues/46).
|
||||
В задаче #45 модель и аудио не запускались.
|
||||
|
||||
Параллельное планирование ASR и диаризации целиком принадлежит
|
||||
[задаче #22](https://git.dementev.space/ddmitry/local-transcriber/issues/22).
|
||||
Автоматическое включение принадлежит
|
||||
[задаче #23](https://git.dementev.space/ddmitry/local-transcriber/issues/23).
|
||||
Joint ASR+diarization и shared-ASR-encoder меняют путь распознавания и исключены
|
||||
из этой карты. ASR-sidecar используется только как свежий quality input.
|
||||
|
||||
Принятие A1 после эксперимента означает право подготовить отдельное
|
||||
production-решение. Оно не меняет [ADR-007](../adr/007-word-level-speaker-diarization.md)
|
||||
и не разрешает поставку NeMo-Speech.cpp в основном dependency graph.
|
||||
@@ -37,13 +37,13 @@
|
||||
Floor берётся сразу 3.13, а не минимально достаточный 3.11: любой подъём версии
|
||||
всё равно требует прогона на всех трёх платформах, и дорого именно тестирование,
|
||||
а не строка в `pyproject.toml`. Один переезд вместо двух. Проект ставится там,
|
||||
где доступны uv и PyPI, поэтому uv сам поднимет нужный интерпретатор; закрытых
|
||||
контуров и оффлайн-зеркал среди пользователей нет.
|
||||
где доступны uv и PyPI; команда установки явно запрашивает Python 3.13, поэтому
|
||||
uv найдёт или скачает нужный интерпретатор. Закрытых контуров и оффлайн-зеркал
|
||||
среди пользователей нет.
|
||||
|
||||
Рабочая версия фиксируется файлом `.python-version` (сейчас его в репозитории
|
||||
нет). Floor разрешает любой интерпретатор от 3.13 и выше, а пин делает окружение
|
||||
одинаковым на разных машинах — иначе расхождение вылезает именно тогда, когда
|
||||
что-то отлаживаешь.
|
||||
Рабочая версия фиксируется файлом `.python-version`. Floor разрешает любой
|
||||
интерпретатор от 3.13 и выше, а пин делает окружение одинаковым на разных
|
||||
машинах — иначе расхождение вылезает именно тогда, когда что-то отлаживаешь.
|
||||
|
||||
Колёса cp313 проверены для всех нативных зависимостей и всех трёх целевых
|
||||
платформ — Windows, Linux, macOS: `openvino-genai` 2026.0.0.0, `ctranslate2`
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
## Не входит
|
||||
|
||||
- Автоматическое включение диаризации без флага и `--no-diarize`.
|
||||
- Аппаратно-зависимое автоматическое включение диаризации.
|
||||
- Параллельный запуск ASR и диаризации.
|
||||
- Сопоставление `Speaker N` с именами участников.
|
||||
- Постоянный кеш разметки говорящих, голосовые эмбеддинги и диагностические
|
||||
@@ -33,10 +33,12 @@
|
||||
|
||||
## Пользовательский интерфейс
|
||||
|
||||
- `--diarize` включает диаризацию. По умолчанию она выключена; ключ в
|
||||
`.transcriber.toml` в первой версии не добавляется.
|
||||
- `--diarize` включает диаризацию, `--no-diarize` явно выключает. Булев ключ
|
||||
`diarize` в `.transcriber.toml` задаёт значение по умолчанию; без него
|
||||
диаризация выключена. CLI-флаги имеют приоритет над конфигом.
|
||||
- `--speakers N`, где `N >= 1`, задаёт известное число участников и сам включает
|
||||
диаризацию. Без него число кластеров определяется автоматически.
|
||||
диаризацию. Без него число кластеров определяется автоматически. Сочетание
|
||||
`--speakers N` с `--no-diarize` считается ошибкой использования.
|
||||
- `--threads N` остаётся единым бюджетом активного CPU-прохода. Значение целиком
|
||||
получает сначала ASR, затем диаризация; `0` оставляет настройки библиотек.
|
||||
- `--verbose` показывает в консоли прогресс, число кластеров и интервалов,
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
# Следующий CPU-эксперимент диаризации
|
||||
|
||||
**Дата:** 2026-08-30
|
||||
|
||||
**Статус:** согласованная постановка эксперимента. Production-конфигурация не
|
||||
менялась.
|
||||
|
||||
## Проблема
|
||||
|
||||
Текущий профиль Pyannote FP32 + WeSpeaker тратит большую часть времени
|
||||
диаризации на эмбеддинги для сильно перекрывающихся окон. Отдельные исследования
|
||||
нашли три независимых рычага: увеличить шаг окна, удешевить эмбеддинги и изменить
|
||||
их исполнение. Полный перебор всех сочетаний слишком дорог и смешивает проверку
|
||||
механизма с выбором итогового профиля.
|
||||
|
||||
Нужен один эксперимент с последовательным отсевом. Он должен показать, есть ли
|
||||
комбинация, которая сокращает время диаризации на Ryzen минимум на 30%, сохраняет
|
||||
принятую проверку качества и остается работоспособной на старом Intel. Ориентир
|
||||
для сильного результата - ускорение примерно в два раза.
|
||||
|
||||
## Сначала прочитать
|
||||
|
||||
- [ADR-007](../adr/007-word-level-speaker-diarization.md) - когда работа касается
|
||||
действующего профиля и проверки качества.
|
||||
- [Предыдущий CPU-тест](../benchmarks/2026-08-30-lightweight-diarization-cpu.md) -
|
||||
при работе с корпусом, метриками и приватными результатами.
|
||||
- [Шаг окна](../research/2026-08-30-sherpa-window-shift.md) - при добавлении
|
||||
`window_shift_ratio` и счетчиков работы.
|
||||
- [Оценка числа говорящих](../research/2026-08-30-diarization-speaker-count.md) -
|
||||
при реализации `Prepare/Finalize`, eigengap и NME.
|
||||
- [Embedding-инференс](../research/2026-08-30-diarization-embedding-inference.md) -
|
||||
при квантизации, конкурентных stream и batch TitaNet.
|
||||
- [Решение об аппаратной матрице](https://git.dementev.space/ddmitry/local-transcriber/issues/35#issuecomment-2243)
|
||||
- при подготовке машин и разборе памяти.
|
||||
|
||||
## Цель и границы
|
||||
|
||||
Эксперимент должен:
|
||||
|
||||
- расширить исследовательский стенд для новых ручек, повторов и счетчиков;
|
||||
- независимо проверить сокращение работы, модель с надежной оценкой числа
|
||||
говорящих и способы ускорить embedding-инференс;
|
||||
- собрать не больше трех комбинаций на Intel, передать их в Ryzen-этап и
|
||||
оставить там не больше двух финалистов после первичного A/B;
|
||||
- выбрать один итоговый вариант либо зафиксировать, почему ни один вариант не
|
||||
прошел;
|
||||
- выпустить публичный обезличенный отчет и сохранить полную переносимую капсулу
|
||||
в закрытой папке Яндекс Диска.
|
||||
|
||||
В эксперимент не входят:
|
||||
|
||||
- изменение CLI, `.transcriber.toml` или модели по умолчанию;
|
||||
- автоматический профиль по ISA или отдельные пользовательские профили;
|
||||
- параллельный запуск ASR и диаризации, GPU, NPU и Mac;
|
||||
- новая полная спектральная кластеризация, NeMo runtime и k-means;
|
||||
- CAMPPlus batch без маски и несколько сессий как самостоятельное направление;
|
||||
- production-переход на победителя по результату одного эксперимента.
|
||||
|
||||
Если каскад не достигает нужной скорости из-за качества, следующий шаг -
|
||||
отдельная карта по end-to-end диаризации. Этот эксперимент ее не начинает.
|
||||
|
||||
## Совпадающий baseline
|
||||
|
||||
Каждое сравнение использует baseline, запущенный на той же машине, в том же
|
||||
режиме питания и на том же стенде:
|
||||
|
||||
- `sherpa-onnx` 1.13.6;
|
||||
- Pyannote Segmentation 3.0 FP32;
|
||||
- WeSpeaker ResNet34 LM FP32;
|
||||
- `window_shift_ratio=0.1`;
|
||||
- `FastClustering` с порогом `0.89` и автоматическим числом кластеров;
|
||||
- последовательный embedding-инференс;
|
||||
- не больше `P` потоков, где `P` - число физических ядер машины.
|
||||
|
||||
Версию `sherpa-onnx`, uv override и `sherpa-onnx-core` обновляют синхронно.
|
||||
Значение `0.1` сохраняет действующее поведение. Изменение зависимости само по
|
||||
себе не считается ускорением. На тех же трех фрагментах W0 сравнивается со
|
||||
свежим запуском 1.13.5 на той же машине. После канонической нумерации кластеров
|
||||
сверяются интервалы, назначение говорящих словам и прежние диагностические
|
||||
метрики. Различия направляются в слуховую проверку отмеченных мест. Baseline
|
||||
1.13.6 принимается только при `3 / 2 / 2` и отсутствии подтвержденной регрессии.
|
||||
|
||||
Время диаризации во всей спецификации означает wall time одного вызова
|
||||
`diarizer.process(samples)`. ASR, загрузка моделей, декодирование и подготовка
|
||||
данных в него не входят.
|
||||
|
||||
Три порога используются во всех этапах:
|
||||
|
||||
- разница до 5% считается равенством по скорости;
|
||||
- 10% - порог продолжения после дешевого отсева;
|
||||
- 30% - итоговый порог `go` на Ryzen.
|
||||
|
||||
## Стенд и данные
|
||||
|
||||
Существующий `scripts/benchmarks/diarization_calibration.py` остается точкой
|
||||
входа, но получает новую версию manifest. Прежний формат жестко закрепляет
|
||||
матрицу FP32/INT8 x WeSpeaker/TitaNet, обязательный свип порогов и
|
||||
`sherpa-onnx` 1.13.5, поэтому старые результаты с новым экспериментом не
|
||||
продолжаются.
|
||||
|
||||
Новая версия manifest и идентификатор ячейки должны учитывать:
|
||||
|
||||
- вариант шага окна, модель, счетчик `N` и способ embedding-инференса;
|
||||
- число outer workers, вид session и бюджеты intra-op/inter-op;
|
||||
- размер batch, где он применим;
|
||||
- машину, число физических ядер, CPUID/ISA и версии библиотек;
|
||||
- номер повтора и позицию в расписании;
|
||||
- этап `intel` или `ryzen`; для Ryzen - исходный commit handoff;
|
||||
- хеш экспериментальной сборки или патча sherpa-onnx;
|
||||
- хеши моделей и приватных входов по прежнему контракту.
|
||||
|
||||
Для QDQ идентичность также включает хеши калибровочных источников,
|
||||
препроцессинг, reader, параметры `quant_pre_process` и `quantize_static`, список
|
||||
исключенных узлов, версии инструментов и хеш готового графа. Для каждого
|
||||
калибровочного элемента manifest хранит идентификатор и SHA-256 исходной записи,
|
||||
а также использованный временной интервал. Вырезанный или перекодированный файл
|
||||
наследует эти данные от исходника. Ни один калибровочный элемент не может
|
||||
происходить из трех контрольных записей, даже из другого интервала.
|
||||
`--validate-only` отклоняет общий исходник; если происхождение нельзя подтвердить
|
||||
или отдельного материала нет, ветвь QDQ пропускается и не блокирует остальные
|
||||
направления. Проверка показывает только хеши и рецепт без приватных путей.
|
||||
|
||||
Каждая измеряемая ячейка выполняется в новом дочернем процессе. Стенд сохраняет
|
||||
сырые результаты каждого повтора и не заменяет их одной агрегированной строкой.
|
||||
При продолжении запуска успешные ячейки пропускаются по стабильному
|
||||
идентификатору, а расписание оставшихся сохраняет заданный A/B-порядок. Режим
|
||||
`--validate-only` показывает полный план без приватных путей и текста.
|
||||
|
||||
### Приватная капсула
|
||||
|
||||
Капсула передается одним ZIP-архивом; `capsule_sha256` означает SHA-256 этого
|
||||
файла. В корне лежит `capsule.json` с идентификатором капсулы, исходным commit,
|
||||
хешем публичного Intel-отчета и SHA-256 каждого файла внутри. Все пути в manifest
|
||||
относительны корня распакованной капсулы.
|
||||
|
||||
Архив содержит:
|
||||
|
||||
- manifest и полный набор входов для трех пятиминутных и трех полных прогонов;
|
||||
- ASR-sidecar, опорные транскрипты и разметку, нужную для метрик и прослушивания;
|
||||
- калибровочный материал и готовый QDQ-граф, если QDQ передается в Ryzen;
|
||||
- сырые Intel-результаты, интервалы, worker requests и журналы, нужные для
|
||||
продолжения и разбора;
|
||||
- локально созданные или измененные модельные артефакты.
|
||||
|
||||
Публично загружаемые базовые модели можно не дублировать в архиве, если manifest
|
||||
фиксирует репозиторий, revision и SHA-256. После распаковки `--validate-only`
|
||||
сверяет внешний хеш архива, `capsule.json`, относительные пути и все внутренние
|
||||
хеши до запуска моделей.
|
||||
|
||||
### Приватность
|
||||
|
||||
За пределами Git остаются manifest, записи, WAV-кеш, ASR-sidecar, опорные
|
||||
транскрипты, сырые интервалы, результаты, worker requests, журналы и материалы
|
||||
слуховой проверки. Рабочий каталог стенда нельзя размещать в `.scratch`: этот
|
||||
каталог локален и целиком игнорируется, поэтому эксперимент из него не
|
||||
переносится между ноутбуками. Временно закрепленные диагностические скрипты уже
|
||||
отслеживаются Git и не меняют этого правила для новых материалов.
|
||||
|
||||
Публичный отчет содержит хеши, агрегаты, конфигурацию и выводы. В него не входят
|
||||
локальные пути, распознанный текст, временные интервалы, имена участников и
|
||||
фрагменты приватных записей. Перед внешним ревью используется только публичный
|
||||
отчет и код стенда.
|
||||
|
||||
## Разделение работы между ноутбуками
|
||||
|
||||
Эксперимент выполняют две зависимые задачи. Один агент не меняет машину в ходе
|
||||
работы:
|
||||
|
||||
| Задача | Машина | Этапы | Результат |
|
||||
|---|---|---|---|
|
||||
| [Intel #41](https://git.dementev.space/ddmitry/local-transcriber/issues/41) | Core i7-6820HQ | 0-4 | стенд, Intel-отчет и handoff |
|
||||
| [Ryzen #42](https://git.dementev.space/ddmitry/local-transcriber/issues/42) | Ryzen 7 8845H | 5-6 | финальный отчет и решение |
|
||||
|
||||
Intel-агент коммитит в обычную ветку своей задачи код стенда и обезличенный
|
||||
отчет. Специальная общая ветка для handoff не создается. Manifest, сырые
|
||||
результаты и другие приватные материалы в Git не попадают. В финальном
|
||||
комментарии к Intel-задаче агент оставляет только индекс:
|
||||
|
||||
- `intel_outcome`: `handoff` или `stop-before-ryzen`;
|
||||
- ветку, точный commit и PR;
|
||||
- путь и SHA-256 публичного Intel-отчета;
|
||||
- число кандидатов и ссылку на их рецепты в отчете;
|
||||
- непрозрачный идентификатор приватной капсулы и ее SHA-256;
|
||||
- ссылку на ограничения в отчете.
|
||||
|
||||
При расхождении источником истины служит файл отчета в указанном commit,
|
||||
комментарий остается указателем. Он не содержит приватных путей, текста или
|
||||
интервалов. Пользователь передает капсулу на второй ноутбук отдельно и сливает
|
||||
Intel-PR обычным merge, без squash или rebase. После этого #41 закрывается и
|
||||
средствами Gitea разблокирует #42. Ryzen-агент сначала читает обе задачи и все
|
||||
комментарии к первой, обновляет `master` и проверяет присутствие handoff-commit
|
||||
в его истории. Затем он сверяет SHA-256 капсулы и запускает `--validate-only`.
|
||||
При наличии кандидатов агент создает обычную ветку #42 от `master`.
|
||||
Несовпадение commit, капсулы или плана переводит задачу в `needs-info`;
|
||||
недостающие данные агент не восстанавливает догадками.
|
||||
|
||||
Штатный путь идет только с Intel на Ryzen. Если на Ryzen обнаружена ошибка
|
||||
стенда, которая делает Intel-результаты недействительными, эксперимент
|
||||
останавливается и требует отдельного исправления. Обычный отсев или проигрыш
|
||||
кандидата не возвращает работу на Intel.
|
||||
|
||||
## Воронка вариантов
|
||||
|
||||
### Этап 0. Проверить механизмы без дорогих замеров
|
||||
|
||||
До CPU-матрицы стенд должен пройти дешевые проверки:
|
||||
|
||||
1. `sherpa-onnx` 1.13.6 импортируется, принимает пять значений шага и сохраняет
|
||||
прежнее поведение при `0.1`.
|
||||
2. Счетчик eigengap и NME проходят синтетические фикстуры из исследования.
|
||||
Затем оба считаются на независимых копиях трех матриц CAMPPlus и трех матриц
|
||||
TitaNet, подготовленных без повторного инференса. Для каждой модели отдельно
|
||||
остается счетчик, который дает точные `3 / 2 / 2`. Если проходят оба, при
|
||||
разнице времени до 10% выбирается более простой eigengap, иначе более
|
||||
быстрый. Модель, для которой не проходит ни один счетчик, выбывает из
|
||||
автоматического режима.
|
||||
3. `Prepare/Finalize` сохраняет пары строк и штатную финализацию. Для известного
|
||||
`N` метки строк и итоговая разметка совпадают с отдельным штатным вызовом на
|
||||
той же матрице.
|
||||
4. WeSpeaker S8S8 QDQ сохраняет входы, выходы, opset, пользовательские метаданные
|
||||
и загружается через `SpeakerEmbeddingExtractor`.
|
||||
5. Shared-session возвращает по одному результату на каждый вход без гонок,
|
||||
пропусков и нечисловых значений. Сравнение ведется с последовательными
|
||||
вызовами на том же наборе входов.
|
||||
|
||||
Числовой дрейф эмбеддингов записывается, но отдельный произвольный порог для него
|
||||
не вводится. Решение принимается по одинаковому числу кластеров, меткам строк и
|
||||
итоговой разметке.
|
||||
|
||||
### Этап 1. Найти безопасный шаг окна на Intel
|
||||
|
||||
На Core i7-6820HQ проверяется текущая связка WeSpeaker с сеткой:
|
||||
|
||||
| Ячейка | `window_shift_ratio` | Роль |
|
||||
|---|---:|---|
|
||||
| W0 | `0.10` | совпадающий baseline |
|
||||
| W1 | `0.15` | осторожное сокращение окон |
|
||||
| W2 | `0.20` | примерно половина исходных окон |
|
||||
| W3 | `0.25` | агрессивный кандидат |
|
||||
| W4 | `0.50` | граничная проверка |
|
||||
|
||||
Для каждой ячейки выполняется один прогон трех пятиминутных фрагментов. Стенд
|
||||
записывает фактический шаг в сэмплах, расчетное число segmentation-окон и число
|
||||
embedding-заданий. Ускорение без соответствующего изменения счетчиков считается
|
||||
необъясненным и не проходит дальше до разбора.
|
||||
|
||||
Все значения, прошедшие обязательные условия, ранжируются по времени
|
||||
диаризации. При равенстве по скорости выбирается меньший шаг: он меньше отходит
|
||||
от принятой разметки. Если лучший по времени шаг ухудшает purity или число слов
|
||||
без говорящего, до сборки комбинаций прослушиваются отмеченные пятиминутные
|
||||
случаи. Рабочим становится лучший шаг, достигший порога продолжения. Если такого
|
||||
нет, рабочим остается `0.1`, а ветвь шага считается проигравшей.
|
||||
|
||||
### Этап 2. Выбрать модель и счетчик говорящих на Intel
|
||||
|
||||
При `window_shift_ratio=0.1` сравниваются:
|
||||
|
||||
| Ячейка | Эмбеддинги | Оценка числа говорящих |
|
||||
|---|---|---|
|
||||
| M0 | WeSpeaker FP32 | текущий `FastClustering`, порог `0.89` |
|
||||
| M2 | CAMPPlus | счетчик, выбранный для CAMPPlus, + `FastClustering(N)` |
|
||||
| M3 | TitaNet small | счетчик, выбранный для TitaNet, + `FastClustering(N)` |
|
||||
|
||||
M1 намеренно отсутствует: отдельный контроль `Prepare/Finalize` перенесен в
|
||||
дешевую проверку этапа 0 и не получает CPU-ячейку.
|
||||
|
||||
M0, M2 и M3 получают по одному прогону трех фрагментов. Быстрая модель проходит
|
||||
дальше, если выдерживает обязательные условия и достигает порога продолжения
|
||||
относительно M0. Малая разница не уточняется дополнительными повторами.
|
||||
Регрессия диагностических метрик требует прослушать отмеченные пятиминутные
|
||||
случаи до сборки комбинации.
|
||||
|
||||
### Этап 3. Проверить исполнение эмбеддингов на Intel
|
||||
|
||||
#### QDQ WeSpeaker
|
||||
|
||||
S8S8 QDQ сравнивается с FP32 на Intel при шаге `0.1`; достаточно одного A/B по
|
||||
трем фрагментам. Если QDQ проходит обязательные условия и проверку памяти, он
|
||||
остается допустимым для C2 даже без порога продолжения на Intel: возможный
|
||||
выигрыш на Ryzen зависит от VNNI/AVX-512. Изолированный Ryzen A/B выполняется
|
||||
уже после handoff, в этапе 5.
|
||||
|
||||
U8U8 допускается как единственный запасной вариант, если S8S8 достигает порога
|
||||
продолжения по времени диаризации, но не проходит качество. Другие форматы
|
||||
квантизации в матрицу не добавляются.
|
||||
|
||||
#### Shared-session streams
|
||||
|
||||
На Intel сравниваются четыре конфигурации при общем бюджете `P`; достаточно
|
||||
одного A/B по трем фрагментам. Лучшая shared-session конфигурация, прошедшая
|
||||
обязательные условия и проверку памяти, остается допустимой для C2 даже без
|
||||
порога продолжения. Изолированный Ryzen A/B выполняется в этапе 5.
|
||||
|
||||
| Ячейка | Outer workers | Intra-op | Session |
|
||||
|---|---:|---:|---|
|
||||
| E0 | `1` | `P` | одна, текущий внутренний параллелизм |
|
||||
| E1 | `2` | общий pool `P` | одна |
|
||||
| E2 | `P` | `1` | одна |
|
||||
| E3 | `P` | `1` на session | отдельные session, отрицательный контроль |
|
||||
|
||||
`inter_op_threads=1` во всех ячейках. Измеряется время полного набора
|
||||
embedding-вызовов. Latency одного удачного stream для решения не используется.
|
||||
E3 не становится кандидатом, если только не опровергает ожидаемый рост RSS и не
|
||||
выигрывает у shared-session по времени диаризации.
|
||||
|
||||
#### Batch TitaNet
|
||||
|
||||
Batch TitaNet открывается только после прохождения M3. Сначала сравниваются batch
|
||||
`1` и `4` на одинаковом наборе переменных длин. Batch проходит к CPU-замеру,
|
||||
если сохраняет число кластеров, метки строк и итоговую разметку на всех трех
|
||||
контрольных матрицах. CAMPPlus batch и подбор размера больше `4` отложены.
|
||||
|
||||
### Этап 4. Собрать комбинации на Intel
|
||||
|
||||
Полного декартова перебора нет. На Intel собираются максимум три явно заданные
|
||||
комбинации:
|
||||
|
||||
| Ячейка | Состав |
|
||||
|---|---|
|
||||
| C0 | рабочий шаг + WeSpeaker FP32 + последовательный инференс |
|
||||
| C1 | рабочий шаг + сильнейшая ветвь по Intel |
|
||||
| C2 | рабочий шаг + заранее определенный ISA-стек |
|
||||
|
||||
Для C1 рассматриваются ветви, достигшие порога продолжения на Intel; сильнейшая
|
||||
определяется по сокращению времени диаризации. C2 начинается с рабочего шага и
|
||||
WeSpeaker FP32. Если QDQ выдержал обязательные условия и память, он заменяет
|
||||
FP32 независимо от скорости на Intel. Затем добавляется лучшая допустимая
|
||||
shared-session конфигурация для WeSpeaker. Если ни QDQ, ни shared-session не
|
||||
допустимы, C2 собирается как совместимый стек прошедших Intel-ветвей. Так
|
||||
ISA-кандидаты доходят до Ryzen без предварительного Ryzen-замера. Batch совместим
|
||||
только с TitaNet. Если C1 и C2 совпадают, дубликат не запускается. C0 совпадает
|
||||
с baseline и тоже не запускается, если рабочим остался шаг `0.1`.
|
||||
|
||||
Каждая существующая комбинация получает один прогон трех фрагментов на Intel.
|
||||
Все комбинации, выдержавшие обязательные условия и память, передаются в handoff;
|
||||
их остается не больше трех. Если прошел только новый шаг окна, передается один
|
||||
C0. Если новых комбинаций нет, Intel-агент фиксирует `stop-before-ryzen`:
|
||||
Ryzen-задача подтверждает итог по handoff и не запускает модели.
|
||||
|
||||
### Этап 5. Принять финалиста на Ryzen
|
||||
|
||||
Ryzen-агент начинает с восстановления handoff. Если передано ноль кандидатов и
|
||||
Intel-итог равен `stop-before-ryzen`, после проверки commit, SHA-256 капсулы и
|
||||
`--validate-only` он фиксирует итог `stop` и закрывает задачу без запуска
|
||||
моделей. При наличии кандидатов агент воспроизводит совпадающий baseline и
|
||||
выполняет изолированные QDQ и shared-session A/B для используемых механизмов.
|
||||
Каждый такой A/B состоит из одного прогона baseline и одного прогона кандидата
|
||||
на трех фрагментах. Порядок baseline и кандидата меняется между парами. Эти A/B
|
||||
проверяют обязательные условия и объясняют вклад механизма. Проигрыш по скорости
|
||||
сам по себе не снимает переданную комбинацию; до первичного A/B она выбывает
|
||||
только при нарушении обязательных условий или правила памяти.
|
||||
|
||||
Затем первичный A/B получают все переданные комбинации, но не больше трех.
|
||||
Каждый A/B состоит из одного прогона baseline и одного прогона кандидата на трех
|
||||
фрагментах. Порядок меняется для следующей пары. Вариант, который нарушает
|
||||
обязательные условия или не достигает порога продолжения, выбывает без
|
||||
дополнительных прогонов.
|
||||
|
||||
Не больше двух лучших вариантов, прошедших первичный A/B, получают по пять
|
||||
чередующихся A/B-циклов. Они выбираются по времени диаризации, при равенстве по
|
||||
скорости - по меньшему RSS, затем по меньшему числу отличий от baseline. При
|
||||
полном равенстве порядок C0, C1, C2 служит последним тай-брейком. Один цикл
|
||||
содержит все три фрагмента; результат цикла - сумма времени диаризации. Итогом
|
||||
служат медиана пяти парных ускорений и MAD. Доверительные интервалы и новые
|
||||
повторы после пяти циклов не требуются.
|
||||
|
||||
Итоговая комбинация проходит условие скорости, если достигает итогового порога
|
||||
на Ryzen. Примерно двукратное ускорение остается ориентиром. На Intel процент
|
||||
допустимого замедления заранее не задается. Если финалист медленнее Intel
|
||||
baseline минимум на порог продолжения, эксперимент возвращает `needs-decision`
|
||||
вместо автоматического `go`: приемлемость фактической регрессии решает владелец
|
||||
после замеров.
|
||||
|
||||
### Этап 6. Проверить полные записи
|
||||
|
||||
Один победитель и совпадающий baseline 1.13.6 однократно обрабатывают три полные
|
||||
записи на Ryzen. Эти прогоны проверяют перенос результата за границы
|
||||
пятиминутных фрагментов; единственное время полного прогона не заменяет
|
||||
итоговый порог скорости.
|
||||
|
||||
В слуховой проверке baseline и кандидат сопоставляются по пяти группам:
|
||||
|
||||
- короткие ответы и перебивки;
|
||||
- смены говорящего;
|
||||
- перекрывающуюся речь;
|
||||
- устойчивость соответствия голоса кластеру;
|
||||
- слова без говорящего и малые остаточные кластеры.
|
||||
|
||||
Вердикт и краткие причины сохраняются в приватной капсуле. Публичный отчет
|
||||
содержит только итог `пройдено` или `не пройдено` по каждой категории.
|
||||
|
||||
## Метрики
|
||||
|
||||
### Работа и скорость
|
||||
|
||||
Для каждой ячейки сохраняются:
|
||||
|
||||
- wall time, user/system/total CPU, средняя загрузка ядер и RTF;
|
||||
- времена segmentation, embeddings, clustering и всего вызова;
|
||||
- фактический шаг, число segmentation-окон и embedding-заданий;
|
||||
- число строк подготовленной матрицы, выбранные `p` и `N`, время счетчика;
|
||||
- число embedding-вызовов, batch, workers, session и бюджеты потоков.
|
||||
|
||||
### Качество
|
||||
|
||||
Обязательные условия:
|
||||
|
||||
- точное число кластеров `3 / 2 / 2` на контрольных фрагментах;
|
||||
- неизменный распознанный текст с точностью до прежней нормализации пробелов;
|
||||
- повторяемые `p`, `N`, метки строк и финализация на одной матрице;
|
||||
- конечные эмбеддинги и отсутствие потерь или дублей входов.
|
||||
|
||||
Диагностика:
|
||||
|
||||
- `mapped speaker purity` на двух записях с опорной разметкой;
|
||||
- число слов без говорящего;
|
||||
- число и длительность малых кластеров;
|
||||
- доля времени речи в кластерах сверх ожидаемых;
|
||||
- доля назначенных слов в тех же кластерах.
|
||||
|
||||
Последние два показателя получают разные имена и единицы. Дополнительными
|
||||
считаются кластеры после `N` крупнейших по времени речи. Доля времени использует
|
||||
общее время речи в знаменателе, доля слов - все слова, назначенные известным
|
||||
кластерам.
|
||||
|
||||
Прокси-метрики не получают произвольных жестких порогов. Ухудшение purity или
|
||||
рост слов без говорящего относительно совпадающего baseline помечает случаи для
|
||||
слуховой проверки. Победитель не принимается, если слуховая проверка
|
||||
подтверждает регрессию коротких реплик, смен говорящего или устойчивости
|
||||
кластеров.
|
||||
|
||||
ASR выполняется один раз при подготовке sidecar и не входит в измеряемые ячейки.
|
||||
Каждая ячейка использует тот же набор слов. Проверка неизменности текста
|
||||
сравнивает сборку слов после назначения говорящих с sidecar и ловит потерю,
|
||||
дубль или перестановку слов; она не запускает ASR повторно.
|
||||
|
||||
### Память и машина
|
||||
|
||||
Для каждого запуска фиксируются точный SKU, физические и логические ядра, RAM,
|
||||
CPUID/ISA, ОС, версии библиотек, питание, power mode, governor, turbo, power
|
||||
limits, температуры, throttling, swap и число потоков.
|
||||
|
||||
Обе машины работают от сети в стабильном `balanced`-режиме. A/B чередуются,
|
||||
чтобы прогрев не давал преимущества одному варианту. Прогон с throttling
|
||||
или сменой режима питания не учитывается и повторяется один раз после
|
||||
стабилизации. Абсолютный температурный порог не задается.
|
||||
|
||||
Peak RSS записывается для каждой ячейки и сравнивается с baseline той же машины.
|
||||
RSS выше 2 ГиБ на машине с 16 ГиБ RAM требует разбора, но сам по себе не
|
||||
отбрасывает вариант. Правило памяти останавливает ячейку при OOM, активной
|
||||
подкачке, росте RSS между повторами без плато или линейном росте памяти с числом
|
||||
workers или session.
|
||||
|
||||
## Правила решения
|
||||
|
||||
Результаты рассматриваются в таком порядке:
|
||||
|
||||
1. обязательные условия качества и корректности;
|
||||
2. медианное сокращение времени диаризации на Ryzen;
|
||||
3. память;
|
||||
4. сложность будущей реализации и сопровождения.
|
||||
|
||||
Практически незначимая разница не получает дополнительных прогонов. При
|
||||
равенстве по скорости выбирается более простой и экономный по памяти вариант.
|
||||
Ни один экспериментальный результат автоматически не меняет production-профиль.
|
||||
|
||||
`stop-before-ryzen` - промежуточный итог Intel-этапа; после проверки handoff он
|
||||
становится итогом `stop`. `needs-info` - состояние заблокированной задачи, а не
|
||||
результат эксперимента.
|
||||
|
||||
Возможны четыре итоговых исхода:
|
||||
|
||||
- `go`: один вариант достигает итогового порога на Ryzen и проходит все
|
||||
проверки;
|
||||
- `needs-decision`: Ryzen проходит итоговый порог, но Intel показывает
|
||||
практически значимую регрессию;
|
||||
- `stop`: baseline 1.13.6 не принят либо ни один вариант не проходит скорость,
|
||||
качество или память; причины сохраняются по каждой ветви;
|
||||
- `new map`: сокращение окон и цены эмбеддинга упирается в качество раньше
|
||||
порога скорости, поэтому отдельно рассматривается end-to-end архитектура.
|
||||
|
||||
## Проверка реализации стенда
|
||||
|
||||
Минимальная автоматическая проверка:
|
||||
|
||||
```bash
|
||||
uv run pytest -q \
|
||||
tests/test_diarization_calibration.py \
|
||||
tests/test_speaker_diarizer.py \
|
||||
tests/test_diarization.py
|
||||
```
|
||||
|
||||
Тесты должны покрывать новую схему manifest и идентичность ячеек, расписание и
|
||||
повторы, смешанные результаты счетчиков, ветвь только с шагом окна, совместимый
|
||||
стек, наследование происхождения QDQ-материала и запрет общего исходника с
|
||||
контрольными записями, обязательные условия, два показателя остаточных кластеров,
|
||||
контроль общего бюджета потоков, продолжение незавершенного запуска, границу
|
||||
этапов Intel/Ryzen, не больше трех handoff-кандидатов и отсутствие приватного
|
||||
текста в ошибках.
|
||||
|
||||
До моделей выполняются:
|
||||
|
||||
```bash
|
||||
uv run python scripts/benchmarks/diarization_calibration.py --help
|
||||
|
||||
uv run python scripts/benchmarks/diarization_calibration.py \
|
||||
--manifest /private/path/manifest.json \
|
||||
--validate-only
|
||||
```
|
||||
|
||||
Intel-отчет должен позволять восстановить отсев и точные рецепты переданных
|
||||
комбинаций по агрегатам и хешам. Финальный Ryzen-отчет ссылается на него и
|
||||
добавляет первичный A/B, финальные циклы и решение. Полное воспроизведение
|
||||
использует приватную капсулу из закрытой папки Яндекс Диска.
|
||||
|
||||
## Отклоненные варианты
|
||||
|
||||
- Полный декартов перебор отклонен: большая часть ячеек повторяет уже
|
||||
проигравшие механизмы и не влияет на выбор.
|
||||
- Пять повторов каждой ячейки отклонены: дорогая точность нужна только для
|
||||
финального A/B. Первичный отсев использует порог продолжения.
|
||||
- Только шаг окна отклонен: он не проверяет более дешевые модели и исполнение,
|
||||
которые могут складываться с сокращением работы.
|
||||
- Только современный CPU отклонен: он скрывает регрессии на AVX2 и не дает
|
||||
понять, зависит ли выигрыш от ISA.
|
||||
- Полная матрица на Ryzen отклонена: старый Intel дешевле для первичного отсева.
|
||||
Ryzen получает максимум три комбинации для одного первичного A/B и оставляет
|
||||
максимум две для дорогих циклов.
|
||||
- Сохранение текущего профиля остается допустимым итогом, если практического
|
||||
победителя нет.
|
||||
|
||||
## Критерии завершения задач эксперимента
|
||||
|
||||
- [ ] Стенд и тесты реализуют описанную воронку и не меняют пользовательский
|
||||
контракт диаризации.
|
||||
- [ ] Intel-задача принимает baseline и завершает этапы 0-4 либо фиксирует
|
||||
подтвержденную регрессию как `stop-before-ryzen`; в обоих случаях коммитит
|
||||
обезличенный отчет и публикует полный handoff-комментарий.
|
||||
- [ ] Ryzen-задача начинает с указанного commit и проверенной капсулы, затем
|
||||
воспроизводит свой baseline без возврата к обычным Intel-замерам.
|
||||
- [ ] Все пройденные и остановленные ветви имеют явную причину и ссылку на
|
||||
ячейки результата.
|
||||
- [ ] Финальный A/B завершен либо зафиксировано, почему до него не дошел ни один
|
||||
вариант.
|
||||
- [ ] При наличии победителя выполнена проверка полных записей и слуховая
|
||||
приемка.
|
||||
- [ ] Публичный отчет свободен от приватных данных; полная капсула сохранена
|
||||
только в закрытой папке Яндекс Диска.
|
||||
- [ ] Итог формулирует следующее решение: готовить production-изменение,
|
||||
вынести измеренную регрессию на Intel на решение владельца, сохранить текущий
|
||||
профиль или открыть новую карту end-to-end диаризации.
|
||||
@@ -0,0 +1,489 @@
|
||||
# Эксперимент Streaming Sortformer v2 Q8_0 на Ryzen CPU
|
||||
|
||||
**Дата:** 2026-09-01
|
||||
|
||||
**Статус:** готовый контракт следующего эксперимента, исполняемый в
|
||||
[задаче #46](https://git.dementev.space/ddmitry/local-transcriber/issues/46).
|
||||
Запуск модели и аудио начинается после подготовки свежей закрытой основы.
|
||||
|
||||
## Цель и исходы
|
||||
|
||||
Эксперимент отвечает на один вопрос: сокращает ли Streaming Sortformer v2
|
||||
Q8_0 через NeMo-Speech.cpp время построения разметки говорящих на целевом Ryzen
|
||||
минимум на 30% относительно свежего последовательного W0 при приемлемом
|
||||
качестве и памяти.
|
||||
|
||||
Допустимы три исхода:
|
||||
|
||||
- `go`: медиана пяти финальных парных ускорений не ниже 30%, автоматические и
|
||||
слуховые gates пройдены;
|
||||
- `stop`: нарушен обязательный gate, первичное ускорение ниже 10%, финальная
|
||||
медиана ниже 30% или слуховая проверка подтвердила регрессию;
|
||||
- `needs-user`: отсутствует подтверждение свежей опоры или требуемое решение
|
||||
по прослушиванию. Это состояние ожидания, его нельзя публиковать как результат
|
||||
кандидата.
|
||||
|
||||
`go` разрешает подготовку отдельного production-решения. Экспериментальный
|
||||
runtime остается изолированным, а production-профиль, CLI, config и модель по
|
||||
умолчанию сохраняются.
|
||||
|
||||
## Сначала прочитать
|
||||
|
||||
1. Прочитать [карту архитектур](../research/2026-09-01-end-to-end-cpu-diarization-map.md),
|
||||
особенно разделы "Граница выводов", A1 и формулу полной замены.
|
||||
Критерий завершения: агент может объяснить, почему старые значения
|
||||
`3 / 2 / 2`, unmatched words и purity не являются порогами нового опыта.
|
||||
2. Прочитать [ADR-007](../adr/007-word-level-speaker-diarization.md) и
|
||||
[старую спецификацию](2026-08-30-cpu-diarization-experiment.md). Критерий
|
||||
завершения: сохранены каноническое сведение слов, правила 5%/10%/30% и
|
||||
memory gate.
|
||||
3. Прочитать `src/local_transcriber/benchmark_experiment.py` и
|
||||
`tests/test_benchmark_experiment.py` перед реализацией. Критерий завершения:
|
||||
новый стенд использует отдельный модуль и schema, не принимает старый
|
||||
manifest или закрытые хранилища задач #41/#42.
|
||||
|
||||
## Границы
|
||||
|
||||
В scope входят свежая закрытая основа, точный W0/Sortformer A/B на Ryzen,
|
||||
проверка трех полных записей после прохождения 30%-го порога на фрагментах и
|
||||
слуховая приемка.
|
||||
|
||||
За пределами scope остаются:
|
||||
|
||||
- replay W/M/Q/E/C и восстановление старых patch/wheel;
|
||||
- C1; ее повторное открытие требует отдельной свежей W0/C1 пары и нового
|
||||
слухового материала;
|
||||
- Intel и другие CPU;
|
||||
- production integration, dependency lock, упаковка runtime и изменение ADR;
|
||||
- параллельное планирование ASR/диаризации - это
|
||||
[задача #22](https://git.dementev.space/ddmitry/local-transcriber/issues/22);
|
||||
- автоматическое включение - это
|
||||
[задача #23](https://git.dementev.space/ddmitry/local-transcriber/issues/23);
|
||||
- joint ASR+diarization и shared-ASR-encoder. ASR создает только quality
|
||||
sidecar до измерений.
|
||||
|
||||
## Закрепленные реализации
|
||||
|
||||
### W0
|
||||
|
||||
W0 использует действующий последовательный путь на восьми физических ядрах:
|
||||
|
||||
- `sherpa-onnx/core 1.13.6`;
|
||||
- Pyannote Segmentation 3.0 FP32, SHA-256
|
||||
`220ad67ca923bef2fa91f2390c786097bf305bceb5e261d4af67b38e938e1079`;
|
||||
- WeSpeaker ResNet34 LM FP32, SHA-256
|
||||
`e9848563da86f263117134dfd7ad63c92355b37de492b55e325400c9d9c39012`;
|
||||
- `window_shift_ratio=0.1`;
|
||||
- `FastClustering(auto, 0.89)`;
|
||||
- `provider=cpu` для segmentation и embeddings;
|
||||
- `debug=True` для обеих model configs: stage times читаются из штатного
|
||||
debug-журнала и входят в измерительный рецепт;
|
||||
- `min_duration_on=0.3`, `min_duration_off=0.5`;
|
||||
- один worker, одна session, `intra_op_threads=8`, `inter_op_threads=1`, batch 1;
|
||||
- штатный непатченный `diarizer.process(samples)`.
|
||||
|
||||
Старые experimental patch/wheel не загружаются и не восстанавливаются. До
|
||||
первого запуска стенд проверяет версии, хеши и все effective values рецепта.
|
||||
Критерий завершения: `--validate-only` показывает рецепт `W0-FRESH` и
|
||||
останавливается при любом несовпадении.
|
||||
|
||||
Закрытые хранилища задач #41/#42 временно сохраняются до завершения и приемки
|
||||
этого эксперимента как архив только для чтения. Стенд не получает их пути, не
|
||||
читает старые manifest и результаты и отклоняет их ID при валидации. Архив не
|
||||
является входом, базой сравнения или условием получения нового результата.
|
||||
|
||||
### Sortformer
|
||||
|
||||
Runtime:
|
||||
|
||||
- репозиторий [NVIDIA/NeMo-Speech.cpp](https://github.com/NVIDIA/NeMo-Speech.cpp);
|
||||
- commit `4f9676226f667d14608487df744f375db87127f8`;
|
||||
- license Apache-2.0;
|
||||
- source build штатным CPU-only preset:
|
||||
`git submodule update --init ggml`, затем `scripts/configure.sh cpu-diar` и
|
||||
`cmake --build --preset cpu-diar`
|
||||
([инструкция build](https://github.com/NVIDIA/NeMo-Speech.cpp/blob/4f9676226f667d14608487df744f375db87127f8/docs/build.md),
|
||||
[точный preset](https://github.com/NVIDIA/NeMo-Speech.cpp/blob/4f9676226f667d14608487df744f375db87127f8/CMakePresets.json));
|
||||
- preset использует Release/Ninja, включает только diarization и отключает
|
||||
patched GGML; runtime запускается с `gpu=-1`, одним процессом и одним stream;
|
||||
- CPU graph использует закрепленные в этом commit четыре внутренних потока;
|
||||
runtime knob для их изменения отсутствует
|
||||
([вызов scheduler](https://github.com/NVIDIA/NeMo-Speech.cpp/blob/4f9676226f667d14608487df744f375db87127f8/src/runtime/ggml/session.cpp#L903));
|
||||
- submodule commits, compiler, CMake options и SHA-256 готового executable и
|
||||
shared library сохраняются в новом manifest.
|
||||
|
||||
Модель:
|
||||
|
||||
- repo `nvidia/diar_streaming_sortformer_4spk-v2`;
|
||||
- revision `5240a64075176943f677d30fa2171c780229f341`;
|
||||
- файл `diar_streaming_sortformer_4spk-v2.q8_0.gguf`;
|
||||
- размер `147075776` байт;
|
||||
- SHA-256 `0679cfeb1ce356d0dea9470b31274f4bfc7eb927497d82005483770666da998a`;
|
||||
- license CC BY 4.0.
|
||||
|
||||
Эти revision, размер и хеш также закреплены в
|
||||
[официальном model index выбранного runtime](https://github.com/NVIDIA/NeMo-Speech.cpp/blob/4f9676226f667d14608487df744f375db87127f8/models/index.json#L75-L88).
|
||||
После подготовки байтов стенд работает без сети.
|
||||
|
||||
Кандидат вызывается через
|
||||
[standalone C ABI](https://github.com/NVIDIA/NeMo-Speech.cpp/blob/4f9676226f667d14608487df744f375db87127f8/include/nemo_speech/diar.h),
|
||||
чтобы сохранить effective config и покадровую диагностику. Stateful streaming
|
||||
получает точную геометрию NVIDIA в 80-мс кадрах:
|
||||
|
||||
| Поле | Значение |
|
||||
|---|---:|
|
||||
| `chunk_frames` | `340` |
|
||||
| `right_context_frames` | `40` |
|
||||
| `left_context_frames` | `0` |
|
||||
| `fifo_frames` | `40` |
|
||||
| `spkcache_frames` | `188` |
|
||||
| `update_period_frames` | `300` |
|
||||
|
||||
Сумма chunk и right context дает input buffer latency 30,4 с. Опубликованный
|
||||
RTF этой конфигурации измерен на RTX 6000 Ada и в CPU-гипотезу не переносится
|
||||
([model card](https://huggingface.co/nvidia/diar_streaming_sortformer_4spk-v2#setting-up-streaming-configuration)).
|
||||
Full-attention `--offline` не используется.
|
||||
|
||||
Post-processing передается явно при каждом запросе:
|
||||
|
||||
| Поле | Значение |
|
||||
|---|---:|
|
||||
| `onset` | `0.641` |
|
||||
| `offset` | `0.561` |
|
||||
| `pad_onset_sec` | `0.229` |
|
||||
| `pad_offset_sec` | `0.079` |
|
||||
| `min_duration_sec` | `0.511` |
|
||||
| `min_gap_sec` | `0.296` |
|
||||
|
||||
Это callhome-tuned конфигурация закрепленного runtime. Она остается quality
|
||||
риском для русских рабочих встреч и не настраивается по трем контрольным
|
||||
фрагментам. Любое изменение геометрии, post-processing, quantization или
|
||||
runtime commit создает другую гипотезу и требует новой спецификации.
|
||||
|
||||
Критерий завершения подготовки кандидата: сборка выполняет синтетический
|
||||
ABI-тест без аудио, `nemo_speech_diar_num_speakers()` возвращает 4,
|
||||
`nemo_speech_diar_seconds_per_frame()` возвращает 0,08, а manifest содержит все
|
||||
закрепленные значения и хеши. Синтетический тест не заменяет smoke-проверку с
|
||||
аудио.
|
||||
|
||||
## Свежая закрытая основа
|
||||
|
||||
### Шаг 1. Выбрать источники и фрагменты
|
||||
|
||||
Владелец выбирает три сохраненные исходные записи и по одной 300-секундной
|
||||
области в каждой. Набор должен включать русскую рабочую речь, смену говорящего,
|
||||
короткий ответ и перекрытие хотя бы где-то в совокупности. В каждой области и
|
||||
каждой полной записи должно быть от двух до четырех слышимых голосов. Запись с
|
||||
пятью голосами закрывает кандидат до измерения: модель выдает не более четырех
|
||||
выходных голосовых кластеров
|
||||
([technical limitations](https://huggingface.co/nvidia/diar_streaming_sortformer_4spk-v2#technical-limitations)).
|
||||
|
||||
В закрытом manifest сохраняются source hash, точные границы и recipe
|
||||
mono/16 kHz float32. Публичные документы получают только число фрагментов,
|
||||
длительность и агрегаты.
|
||||
|
||||
Критерий завершения: три source hash уникальны, три WAV имеют ровно 300 секунд
|
||||
после единственного закрепленного преобразования, а ни один путь, ID или
|
||||
интервал не попадает в stdout режима `--validate-only`.
|
||||
|
||||
### Шаг 2. Подготовить ASR-sidecar и доступную опорную разметку
|
||||
|
||||
Действующий ASR выполняется один раз для каждой полной записи до измерений.
|
||||
Sidecar хранит канонические слова с временной привязкой и нормализованный текст;
|
||||
fragment-sidecar получается детерминированным срезом той же версии. Затем
|
||||
человек прослушивает каждый фрагмент целиком и фиксирует ожидаемое число
|
||||
голосовых кластеров для фрагментов и полных записей. Опорная разметка фрагмента
|
||||
необязательна: `reference_path` и `reference_sha256` либо заполнены вместе, либо
|
||||
оба равны `null`. При наличии разметки стенд считает mapped purity и выбирает
|
||||
места прослушивания; сама разметка остается приближенной и не объявляется
|
||||
истиной границ.
|
||||
|
||||
Критерий завершения: для каждого фрагмента и каждой полной записи есть
|
||||
подтвержденное человеком `expected_cluster_count` в диапазоне `2..4`, один
|
||||
неизменяемый sidecar и SHA-256. Отсутствие опорной разметки не блокирует
|
||||
измерение и не ослабляет остальные gates. Для канонического Ryzen-прогона
|
||||
используются подтвержденные владельцем числа `3 / 2 / 2`; их привязывают к
|
||||
источникам по хешам, а не по порядку нейтральных имен. Повторное прослушивание
|
||||
для подсчета голосов не требуется.
|
||||
|
||||
### Шаг 3. Пройти пользовательский gate
|
||||
|
||||
До первого модельного запуска владелец подтверждает:
|
||||
|
||||
- право использовать выбранные записи в закрытом локальном опыте;
|
||||
- пригодность трех областей и полных записей, а также
|
||||
`expected_cluster_count` каждого фрагмента и каждой полной записи;
|
||||
- доступность исходных полных записей для этапа после 30%-го порога на
|
||||
фрагментах;
|
||||
- готовность выполнить безусловный финальный слуховой gate, если медиана
|
||||
достигнет 30%. Diagnostics только добавляют обязательные места прослушивания.
|
||||
|
||||
Критерий завершения: в закрытом manifest есть timestamped decision `approved`.
|
||||
Без него исход `needs-user`; стенд не загружает модели.
|
||||
|
||||
В каноническом Ryzen-прогоне решением `approved` служит датированный handoff
|
||||
владельца в комментарии к задаче #46. Нового подтверждения до достижения
|
||||
30%-го порога не требуется.
|
||||
|
||||
## Измеряемая величина и A/B
|
||||
|
||||
Время W0 - wall вокруг одного `diarizer.process(samples)`. Время кандидата -
|
||||
wall вокруг `stream_open -> push all prepared samples -> finish -> segment
|
||||
post-processing`. Для обеих ветвей модель уже загружена, WAV уже декодирован в
|
||||
один и тот же mono/16 kHz float32 buffer. ASR, скачивание, build, model load,
|
||||
декодирование, чтение sidecar, запись artifacts и расчет метрик находятся вне
|
||||
таймера.
|
||||
|
||||
Полная замена оценивается только измерением:
|
||||
|
||||
```text
|
||||
B = measured W0 wall
|
||||
A = measured Sortformer wall
|
||||
speedup_percent = 100 * (B - A) / B
|
||||
```
|
||||
|
||||
Восьмиклеточное историческое Ryzen-разложение не подставляется вместо свежего
|
||||
`B`. CPU prediction для `A` отсутствует.
|
||||
|
||||
Каждая ячейка запускается новым дочерним процессом на одной машине, от сети, в
|
||||
стабильном power mode. Process affinity разрешает те же восемь физических ядер
|
||||
обеим ветвям. W0 получает `intra_op_threads=8`; закрепленный candidate runtime
|
||||
исполняет CPU graph четырьмя внутренними потоками. Это часть сравниваемого
|
||||
runtime recipe. Изменение hardcoded числа потоков образует новую гипотезу.
|
||||
Фиксируются logical/physical cores, CPUID/ISA, RAM, ОС, compiler/runtime
|
||||
versions, affinity, effective threads, power mode, governor, turbo, throttling
|
||||
и swap. Прогон со сменой режима или throttling не учитывается и повторяется
|
||||
один раз после стабилизации.
|
||||
|
||||
### Шаг 4. Принять свежий W0
|
||||
|
||||
Один `W0-FRESH` обрабатывает три фрагмента последовательно. Для каждого
|
||||
сохраняются raw wall/CPU/RSS и stage times; публично выводится сумма. W0 должен
|
||||
дать ровно подтвержденные `expected_cluster_count`, сохранить sidecar-текст и
|
||||
пройти memory gate.
|
||||
|
||||
Критерий завершения: W0 прошел все обязательные gates и его закрытый result
|
||||
имеет SHA-256. При провале итог `stop`: база сравнения непригодна.
|
||||
|
||||
### Шаг 5. Выполнить первичный A/B
|
||||
|
||||
Сразу после принятого W0 запускается одна ячейка кандидата на тех же buffers и в
|
||||
том же порядке фрагментов. Primary order закреплен как `W0 -> candidate`; пять
|
||||
дорогих циклов ниже устраняют постоянный порядок чередованием. Один primary
|
||||
cycle равен сумме трех wall times каждой ветви.
|
||||
|
||||
Критерий завершения: обе ячейки имеют raw result, одинаковый список input
|
||||
hashes и один pair ID; рассчитаны ускорение, CPU и RSS delta.
|
||||
|
||||
Решение после primary:
|
||||
|
||||
- `abs(speedup) <= 5%` - результат фиксируется как равенство по скорости;
|
||||
- `speedup < 10%` - `stop`: причина `equal-speed` для равенства по скорости,
|
||||
иначе `below-continuation`;
|
||||
- `speedup >= 10%` при всех gates - перейти к пяти финальным циклам;
|
||||
- любой обязательный quality/memory/Sortformer gate - немедленный `stop`.
|
||||
|
||||
### Шаг 6. Выполнить пять финальных циклов
|
||||
|
||||
Каждый цикл содержит свежую ячейку W0 и свежую ячейку кандидата на всех трех
|
||||
фрагментах. Primary pair в пять финальных циклов не входит. Порядок закреплен
|
||||
заранее и чередуется так:
|
||||
|
||||
1. final 1: `candidate -> W0`;
|
||||
2. final 2: `W0 -> candidate`;
|
||||
3. final 3: `candidate -> W0`;
|
||||
4. final 4: `W0 -> candidate`;
|
||||
5. final 5: `candidate -> W0`.
|
||||
|
||||
Вместе с primary `W0 -> candidate` это исключает выбор порядка по уже видимому
|
||||
результату. Дополнительные варианты, модели и настройки не добавляются.
|
||||
|
||||
Критерий завершения: получены пять парных ускорений, их медиана и MAD; все
|
||||
десять ячеек прошли gates. Медиана не ниже 30% открывает проверку полных записей.
|
||||
Медиана ниже 30% дает `stop`. Дополнительные повторы не назначаются.
|
||||
|
||||
### Шаг 7. Проверить полные записи и пройти слуховой gate
|
||||
|
||||
После медианы не ниже 30% W0 и candidate однократно обрабатывают три полные
|
||||
исходные записи. Скорость полных прогонов остается диагностикой и не заменяет
|
||||
пятипарную медиану.
|
||||
|
||||
Владелец получает blinded A/B материал по пяти категориям:
|
||||
|
||||
- короткие ответы и перебивки;
|
||||
- смены говорящего;
|
||||
- перекрывающаяся речь;
|
||||
- устойчивость соответствия голоса кластеру;
|
||||
- слова без говорящего и малые остаточные кластеры.
|
||||
|
||||
Материал включает все места, отмеченные сравнительными diagnostics, и
|
||||
фиксированную случайную выборку совпадающих мест. Порядок A/B и имя кандидата
|
||||
раскрываются после вердикта. По каждой категории владелец записывает `pass` или
|
||||
`fail` и короткую причину в закрытом хранилище; публично остается только
|
||||
вердикт.
|
||||
|
||||
Критерий завершения: три пары на полных записях прошли автоматические gates,
|
||||
все пять слуховых категорий имеют решение. Любой подтвержденный `fail` дает
|
||||
`stop`; полный `pass` завершает эксперимент как `go`.
|
||||
|
||||
## Gates и диагностика
|
||||
|
||||
### Обязательное качество и корректность
|
||||
|
||||
Каждая ячейка W0 и кандидата обязана выполнить все условия:
|
||||
|
||||
- число голосовых кластеров каждого обрабатываемого контрольного объекта -
|
||||
фрагмента или полной записи - точно равно свежему, подтвержденному человеком
|
||||
`expected_cluster_count`;
|
||||
- нормализованный текст и порядок слов побайтно совпадают с одним sidecar;
|
||||
- нет потерянных, продублированных или переставленных слов;
|
||||
- интервалы конечны, имеют положительную длительность, лежат в пределах audio
|
||||
duration с учетом явно заданного padding и отсортированы по началу;
|
||||
- каноническая нумерация меток детерминирована на повторном расчете метрик;
|
||||
- все inputs, параметры и output artifacts имеют SHA-256.
|
||||
|
||||
Purity для фрагментов с опорной разметкой и число слов без говорящего
|
||||
сравниваются с новым `W0-FRESH`. Ухудшение не дает автоматического `fail`, но
|
||||
обязательно добавляет все затронутые места в слуховой gate. Старые абсолютные
|
||||
значения не участвуют.
|
||||
|
||||
Открытый диагностикой слуховой gate не прерывает шаги 5-6. Стенд накапливает
|
||||
затронутые места и добавляет их в материал шага 7. Состояние `needs-user` из-за
|
||||
прослушивания возможно только после достижения 30%-го порога и подготовки
|
||||
полного blinded-материала.
|
||||
|
||||
### Sortformer-specific gate
|
||||
|
||||
Для каждой ячейки кандидата сохраняются и проверяются:
|
||||
|
||||
- `num_speakers=4`, `seconds_per_frame=0.08` и точная geometry/post-processing
|
||||
из этой спецификации;
|
||||
- `frame_count`, `frame_probs_start` и размер доступной матрицы `T x 4`;
|
||||
- каждое сохраненное probability конечно и принадлежит `[0, 1]`;
|
||||
- каждая итоговая speaker label принадлежит `1..4`;
|
||||
- число и суммарная длительность интервалов по каждому каналу;
|
||||
- число коротких интервалов, overlap ratio, unmatched words, малые/остаточные
|
||||
кластеры и mapped purity для фрагментов с опорной разметкой;
|
||||
- wall отдельно для model inference и segment post-processing.
|
||||
|
||||
Любой неверный shape/value/label или эффективная конфигурация, отличная от
|
||||
закрепленной, дает `stop`. Появление лишнего/пропажа ожидаемого кластера дает
|
||||
`stop`. Изменение channel stability, short-turn, overlap, unmatched-word или
|
||||
purity diagnostics открывает слуховую проверку; числовой порог для этих proxy
|
||||
не вводится.
|
||||
|
||||
Жесткий предел четырех выходных голосовых кластеров, риск записей с пятью и
|
||||
более слышимыми голосами и риск non-English/out-of-domain сохраняются в
|
||||
публичном решении даже после `go`; опыт не доказывает применимость к другим
|
||||
встречам.
|
||||
|
||||
### Память
|
||||
|
||||
Peak RSS измеряется process-wide для каждой ячейки. Также сохраняются swap до и
|
||||
после, page faults и RSS после каждого фрагмента.
|
||||
|
||||
Memory gate дает `stop` при OOM, активной подкачке во время ячейки, RSS без плато
|
||||
между повторами или линейном росте RSS с числом обработанных фрагментов. Peak
|
||||
RSS выше 2 ГиБ на машине с 16 ГиБ требует отдельного разбора владельца и
|
||||
состояния `needs-user`; одно превышение не является автоматическим `fail`.
|
||||
|
||||
## Артефакты и приватность
|
||||
|
||||
Checkout и дерево сборки NeMo-Speech.cpp, скачанные веса и новое закрытое
|
||||
хранилище размещаются вне рабочей копии local-transcriber. `.scratch` внутри
|
||||
репозитория целиком игнорируется, но остается локальной частью конкретного
|
||||
checkout и не подходит для закрытого переносимого хранилища. Ни один
|
||||
runtime/model/build artifact или закрытый вход не попадает в Git.
|
||||
|
||||
Новое закрытое хранилище содержит:
|
||||
|
||||
- versioned manifest со schema, clean local-transcriber source commit,
|
||||
source/model/runtime/build hashes и расписанием;
|
||||
- исходные ссылки, подготовленные WAV и recipe преобразования;
|
||||
- ASR-sidecar и имеющуюся опорную разметку;
|
||||
- raw per-fragment results, intervals, word assignments и probability
|
||||
diagnostics;
|
||||
- machine/power/runtime guards и stderr/stdout logs;
|
||||
- материал и решения слуховой проверки;
|
||||
- байты runtime/model либо проверяемый download recipe с revision, size и hash.
|
||||
|
||||
Хранилище не ссылается на закрытые хранилища задач #41/#42 и остается
|
||||
достаточным для разбора собственного результата. После приемки отчета задачи
|
||||
#46 старые архивы можно удалить; их удаление не блокирует ни один шаг и не
|
||||
лишает новый результат доказательной основы.
|
||||
|
||||
Публичный отчет содержит `clean local-transcriber source commit`, точные
|
||||
рецепты, хеши, агрегаты, вердикты gates и итоговое решение. В Git, комментарии
|
||||
Gitea, задания на ревью и вывод `--validate-only` не попадают пути,
|
||||
`source/recording IDs`, текст, временные интервалы, сырые строки, содержимое
|
||||
manifest, логи, имена участников или аудио.
|
||||
|
||||
При нарушении приватности стенд останавливается до публикации. Уже
|
||||
сформированный публичный артефакт удаляется из рабочей ветки и создается заново
|
||||
из безопасных агрегатов.
|
||||
|
||||
## Реализация стенда и тесты
|
||||
|
||||
Новый стенд реализуется отдельным модулем
|
||||
`src/local_transcriber/streaming_sortformer_experiment.py`; существующий
|
||||
`benchmark_experiment.py` остается источником общих проверенных механизмов.
|
||||
Стенд получает отдельную schema и только две recipe: `W0-FRESH` и
|
||||
`SORTFORMER-V2-Q8`. Manifest validation выполняется до загрузки моделей.
|
||||
`--validate-only` проверяет:
|
||||
|
||||
- три уникальных source hashes, 300-секундные фрагменты и fresh-basis marker;
|
||||
- human approval и `expected_cluster_count` каждого фрагмента и каждой полной
|
||||
записи в диапазоне `2..4`;
|
||||
- W0 model/runtime hashes и effective values: CPU provider, `debug=True`,
|
||||
`min_duration_on=0.3`, `min_duration_off=0.5`, threads и clustering;
|
||||
- clean local-transcriber worktree и source commit из manifest;
|
||||
- Sortformer revision, size, SHA-256, runtime commit, submodules, build hashes,
|
||||
CPU backend, geometry и post-processing;
|
||||
- одинаковые input/sidecar hashes обеих ветвей;
|
||||
- заранее созданные primary/final schedules;
|
||||
- отсутствие корней старых хранилищ и legacy ID ячеек.
|
||||
|
||||
Минимальные детерминированные тесты выполняются без модели и аудио:
|
||||
|
||||
```bash
|
||||
uv run pytest -q \
|
||||
tests/test_streaming_sortformer_experiment.py \
|
||||
tests/test_benchmark_experiment.py \
|
||||
tests/test_speaker_diarizer.py \
|
||||
tests/test_diarization.py
|
||||
```
|
||||
|
||||
Новый модуль тестов покрывает отклонение `schema`, несовпадение пинов и хешей,
|
||||
требование свежей опоры, обезличенный вывод `--validate-only`, правило
|
||||
`expected_cluster_count`, точное расписание A/B, границы 5%/10%/30%, gates
|
||||
качества, памяти и Sortformer, переход к прослушиванию, безопасную сериализацию
|
||||
и остановку при первом отказе. Стенд C ABI имеет отдельный синтетический тест
|
||||
размеров структур, выбора CPU и эффективной конфигурации; модель в модульных
|
||||
тестах не загружается.
|
||||
|
||||
Критерий завершения реализации стенда: все детерминированные тесты проходят,
|
||||
`--validate-only` проходит на закрытом manifest и публичный вывод проходит
|
||||
privacy scan. Реализация стенда фиксируется чистым коммитом исходников,
|
||||
совпадающим с manifest. Только после этого разрешен первый запуск модели W0.
|
||||
|
||||
## Критерии завершения эксперимента
|
||||
|
||||
- [ ] Свежая опора собрана в новом закрытом хранилище; подтвержденные входы,
|
||||
доступная разметка и числа голосов связаны по хешам, а старые manifest,
|
||||
расписание и результаты не наследуются.
|
||||
- [ ] Runtime, модель, сборка и оба рецепта закреплены и проверены до моделей.
|
||||
- [ ] Clean local-transcriber source commit сохранен в закрытом хранилище и
|
||||
публичном отчете, а `--validate-only` проверяет его до моделей.
|
||||
- [ ] Свежий W0 принят либо итог `stop` объясняет провал базы сравнения.
|
||||
- [ ] Первичный A/B завершен; решение 10% применено без дополнительных ветвей.
|
||||
- [ ] При продолжении завершены ровно пять финальных циклов, медиана и MAD.
|
||||
- [ ] Каждая выполненная ячейка имеет вердикты качества, текста, памяти и
|
||||
Sortformer.
|
||||
- [ ] При медиане 30% выполнены три пары на полных записях и пять слуховых
|
||||
решений.
|
||||
- [ ] Все остановленные шаги имеют первую точную причину и хеш результата.
|
||||
- [ ] Закрытое хранилище самодостаточно; публичный отчет содержит только безопасные
|
||||
агрегаты и SHA-256.
|
||||
- [ ] Итог равен `go` или `stop`; `needs-user` остается открытым до решения.
|
||||
- [ ] Ни один результат автоматически не меняет production-зависимости,
|
||||
профиль, CLI, config или модель по умолчанию.
|
||||
@@ -0,0 +1,167 @@
|
||||
# Владение выполнением транскрипции
|
||||
|
||||
**Статус:** реализовано. Правила выбора исполнения и optional CUDA — в #2,
|
||||
владение выполнением (`Transcriber`), бюджет потоков ONNX и диагностика —
|
||||
в #56. Windows GPU-прогон без Toolkit остаётся невыполненным (см. ADR-001).
|
||||
|
||||
## Зачем
|
||||
|
||||
Основное направление развития — поддерживаемые модели и движки распознавания,
|
||||
прежде всего ONNX. В перспективе нужны аппаратные варианты исполнения на Mac
|
||||
и через Windows ML, но их внедрение не входит в этот рефакторинг.
|
||||
|
||||
Сейчас CLI хранит модель, backend, путь к модели и фактическое устройство,
|
||||
передаёт их в `_transcribe_file` и обновляет после fallback. Для ONNX часть
|
||||
согласованного состояния находится в adapter, а загруженная модель передаётся
|
||||
отдельно. Это увеличивает interface: caller должен знать порядок вызовов
|
||||
и поддерживать согласованность значений.
|
||||
|
||||
`device` одновременно обозначает движок и аппаратное исполнение. До #2 значение
|
||||
`onnx` выбирало adapter, но не закрепляло CPU: `onnx-asr` по умолчанию использует
|
||||
доступные execution providers, хотя CLI писал `ONNX (CPU)`. В #2 для ASR и VAD
|
||||
закреплён CPU provider. Параметр `cpu_threads` всё ещё принимается ONNX adapter,
|
||||
но не используется при загрузке ASR и VAD.
|
||||
|
||||
## Согласованные ограничения
|
||||
|
||||
- Сохраняются ONNX-ASR, OpenVINO и FasterWhisper. Удаление CUDA-библиотек из
|
||||
обязательной установки не означает удаление FasterWhisper или его CPU-пути.
|
||||
- Приоритет — подключение моделей и вариантов исполнения. Промежуточное
|
||||
сохранение и возобновление обработки совещаний не нужны для этой работы.
|
||||
- Автоматически выбираются только проверенные сочетания модели и исполнения.
|
||||
Новое аппаратное исполнение сначала подключается явно и проверяется отдельно.
|
||||
- CUDA-зависимости устанавливаются по желанию пользователя. Способ установки
|
||||
и включения должен быть описан в документации.
|
||||
- Наличие драйвера NVIDIA не доказывает совместимость железа с runtime.
|
||||
Ноутбук владельца с установленным драйвером и несовместимым старым GPU —
|
||||
обязательный сценарий для проверки нового поведения.
|
||||
- Диаризация остаётся необязательным последующим проходом. Сбой «два
|
||||
собеседника на чёткой записи, найден один говорящий» требует отдельного
|
||||
исследования качества; этот рефакторинг его не исправляет.
|
||||
|
||||
## Форма module
|
||||
|
||||
External seam проходит между caller и выполнением распознавания. Caller
|
||||
создаёт один module для согласованных настроек запуска и последовательно
|
||||
передаёт ему файлы. Module владеет загруженной моделью, связанным adapter,
|
||||
фактическими настройками и допустимыми переходами исполнения. Caller получает
|
||||
результат распознавания и сведения о выполнении, а не внутреннее состояние,
|
||||
которое нужно передать обратно при следующем файле.
|
||||
|
||||
Module создаётся после prescan, когда есть работа. Загрузка и проверка
|
||||
возможностей завершаются до распознавания первого файла: это сохраняет
|
||||
проверку пословного контракта перед включённой диаризацией. Один и тот же
|
||||
interface используется для одного файла и для батча. События прогресса остаются
|
||||
доступны CLI. Публичная функция `transcribe` сохраняется как путь одного файла
|
||||
через тот же module; конкретные имена новых типов здесь не закрепляются.
|
||||
|
||||
Внутри различаются поддерживаемая модель, движок распознавания и способ
|
||||
исполнения. Существующие значения CLI и TOML преобразуются в этот выбор при
|
||||
подготовке запуска. Backend остаётся internal seam с реальными adapters;
|
||||
каталог ONNX, VAD, квантизация и перевод результата библиотеки в сегменты
|
||||
распознавания и слова с временной привязкой остаются в ONNX adapter.
|
||||
Отдельная иерархия adapters для каждого execution provider пока не нужна.
|
||||
|
||||
CLI сохраняет выбор файлов, prescan, пользовательский вывод и итоговый статус.
|
||||
Форматирование, запись Markdown и правила диаризации не поглощаются новым
|
||||
module. Это ограничивает первый шаг владением ASR и его настройками.
|
||||
|
||||
Depth оценивается по обязанностям caller: ему больше не нужно согласовывать
|
||||
model/backend/path/device. Locality — изменения загрузки и исполнения
|
||||
сосредоточены в module и соответствующем adapter. Leverage — один interface
|
||||
покрывает одиночный запуск, батч и их тесты.
|
||||
|
||||
## Выбор исполнения и ошибки
|
||||
|
||||
Ручное включение CUDA состоит из двух явных действий: установки зависимостей
|
||||
и выбора CUDA через CLI либо TOML. Extra `cuda` поддерживает Linux/WSL x86_64
|
||||
и Windows x64 без обязательного системного Toolkit; совместимый драйвер
|
||||
устанавливается отдельно. CUDA не участвует
|
||||
в `auto`, даже если найден `nvidia-smi` или установлены её библиотеки.
|
||||
Так установка драйвера не меняет движок и модель по умолчанию.
|
||||
|
||||
Начальный автоматический путь — ONNX с CPU и существующей моделью по умолчанию.
|
||||
Для ONNX список providers задаётся явно; наличие CoreML в macOS wheel не
|
||||
включает его автоматически. Это делает политику проекта определённой, но
|
||||
не заменяет проверку работы на настоящем Mac.
|
||||
|
||||
Явный аппаратный выбор не подменяется молча другим исполнением. При отсутствии
|
||||
CUDA-библиотек выводится инструкция подключения; при несовместимости железа
|
||||
объясняется отказ выбранного пути. Установка библиотек не предлагается как
|
||||
универсальное исправление аппаратной несовместимости. Остальные существующие
|
||||
правила ошибок и разрешённых fallback сохраняются внутри module; состояние
|
||||
после перехода остаётся согласованным для следующего файла.
|
||||
|
||||
Сведения о выполнении различают запрошенное и выбранное исполнение, модель,
|
||||
квантизацию и настройки потоков. Они не раскрывают model handle. Для ONNX
|
||||
наличие или регистрация provider не выдаётся за доказательство выполнения
|
||||
всего графа на GPU/NPU. Отладочный вывод должен помогать выяснить конфигурацию
|
||||
на чужой машине, включая версии runtime и providers, без доступа к записи.
|
||||
|
||||
## Проверка результата
|
||||
|
||||
Основные сценарии проверяются через external interface с подменой внешних
|
||||
движков, при работающей оркестрации проекта:
|
||||
|
||||
- Пустой prescan не загружает модели; два файла переиспользуют одну модель.
|
||||
- Добавление каталожной ONNX-модели не требует изменений в CLI.
|
||||
- Выбранная квантизация и бюджет потоков действительно передаются ASR и VAD;
|
||||
нулевой бюджет сохраняет настройки потоков библиотек.
|
||||
- Наличие `nvidia-smi` при обычной установке не запускает CUDA и не ведёт
|
||||
через ошибку GPU к FasterWhisper CPU.
|
||||
- Явная CUDA с отсутствующими библиотеками и с несовместимым GPU даёт разные
|
||||
содержательные ошибки. Автоматическая смена модели не маскирует отказ.
|
||||
- Случайно доступный непроверенный ONNX provider не меняет автоматический путь.
|
||||
- Разрешённый fallback не требует переноса состояния в CLI; обычная ошибка
|
||||
файла не повреждает состояние для последующих файлов батча.
|
||||
- Сохраняются явные CPU/OpenVINO-пути, порядок слов, пословный контракт,
|
||||
прогресс, прерывание и существующее поведение необязательной диаризации.
|
||||
|
||||
Тесты ручного переноса внутренних полей заменяются проверками этих сценариев.
|
||||
Тесты преобразования результатов adapters сохраняются на их internal seams.
|
||||
Mock-тесты не подтверждают ускорение или совместимость на Mac/Windows ML;
|
||||
для признания сочетания проверенным нужен реальный прогон с оценкой результата.
|
||||
|
||||
## Документация и прежние решения
|
||||
|
||||
README должен различать обычную установку и подключение CUDA: дополнительные
|
||||
зависимости через один extra на Linux/WSL x86_64 и Windows x64, внешние
|
||||
требования драйвера и Visual C++ Runtime, явный запуск, настройку TOML
|
||||
и диагностику отсутствующих библиотек или несовместимого железа. Команды
|
||||
установки из клона и через `uv tool` проверяются при реализации. CUDA extra
|
||||
оформляется отдельным изменением, чтобы поставку можно было проверить
|
||||
независимо от рефакторинга выполнения.
|
||||
|
||||
При реализации нужно отразить пересмотр прежних решений:
|
||||
|
||||
- [ADR-001](../adr/001-cuda-bootstrap.md): обязательная cuBLAS ради готовности
|
||||
CUDA сразу после установки сменяется подключением по желанию пользователя.
|
||||
- [ADR-002](../adr/002-batch-and-config.md): состояние модели больше не
|
||||
возвращается caller для переноса между файлами. Prescan-first сохраняется.
|
||||
- [ADR-003](../adr/003-pluggable-backends.md): движок и аппаратное исполнение
|
||||
различаются внутри module; device больше не считается полным описанием
|
||||
исполнения. Сохраняются adapters, ленивые imports и единый владелец загрузки.
|
||||
- [ADR-006](../adr/006-onnx-asr-backend.md): автоматический выбор CUDA по
|
||||
`nvidia-smi` заменяется явным включением; модель ONNX по умолчанию сохраняется.
|
||||
- [ADR-007](../adr/007-word-level-speaker-diarization.md): правила пословного
|
||||
результата и диаризации этим решением не пересматриваются.
|
||||
|
||||
Основания для будущих аппаратных вариантов уже собраны в
|
||||
[исследовании runtime](../research/2026-08-12-onnx-runtime-openvino-lifecycle.md).
|
||||
Актуальные описания механизмов: [CoreML EP](https://onnxruntime.ai/docs/execution-providers/CoreML-ExecutionProvider.html)
|
||||
и [Windows ML](https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/overview).
|
||||
Их наличие не является подтверждением выбранных моделей на этих платформах.
|
||||
|
||||
## Объём задач
|
||||
|
||||
Для основного рефакторинга достаточно одной задачи с несколькими коммитами:
|
||||
владение моделью, перевод callers, настройки ONNX и проверка поведения входят
|
||||
в один принимаемый результат. Отдельных задач на каждый файл или коммит нет.
|
||||
|
||||
Существующая [задача #2](https://git.dementev.space/ddmitry/local-transcriber/issues/2)
|
||||
получает принятое решение об optional CUDA вместо дальнейшего исследования:
|
||||
зависимости, ручное включение, автоматический ONNX CPU, диагностика CUDA
|
||||
и документация. Она выполняется первой и даёт основному рефакторингу
|
||||
определённые правила выбора. Фиксация CPU providers ASR/VAD относится к #2;
|
||||
бюджет потоков, владение состоянием и общая диагностика исполнения — к основной
|
||||
задаче. Работа над CoreML, Windows ML и качеством диаризации сюда не входит.
|
||||
+20
-4
@@ -8,21 +8,32 @@ dependencies = [
|
||||
"typer>=0.24.1,<1",
|
||||
"rich>=14.3.3,<15",
|
||||
"faster-whisper>=1.2.1,<2",
|
||||
# CUDA-зависимости Windows проверены для этой сборки; см. ADR-001.
|
||||
"ctranslate2==4.8.1",
|
||||
"httpx>=0.28,<1",
|
||||
"socksio>=1.0.0,<2",
|
||||
# При обновлении сверить блок tool.uv.dependency-metadata ниже.
|
||||
"sherpa-onnx==1.13.5",
|
||||
"nvidia-cublas-cu12>=12.4,<13; sys_platform == 'linux' and platform_machine == 'x86_64'",
|
||||
"sherpa-onnx==1.13.6",
|
||||
"openvino-genai>=2026.3.0.0,<2026.4; sys_platform != 'darwin' and (platform_machine == 'x86_64' or platform_machine == 'AMD64')",
|
||||
"onnx-asr[cpu,hub]>=0.12,<0.13",
|
||||
"onnxruntime>=1.28,<2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
cuda = [
|
||||
# В 12.9.2 добавилась NVRTC: обновлять после проверки DLL и bootstrap.
|
||||
"nvidia-cublas-cu12==12.9.1.4; (sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and (platform_machine == 'AMD64' or platform_machine == 'x86_64'))",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
transcribe = "local_transcriber.cli:app"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"kaldi-native-fbank>=1.22,<2",
|
||||
"numpy==2.4.3",
|
||||
"onnx>=1.18,<2",
|
||||
"psutil>=7,<8",
|
||||
"pytest",
|
||||
]
|
||||
|
||||
@@ -35,10 +46,15 @@ testpaths = ["tests"]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/local_transcriber"]
|
||||
exclude = [
|
||||
"src/local_transcriber/benchmark_experiment.py",
|
||||
"src/local_transcriber/experimental_diarization.py",
|
||||
"src/local_transcriber/streaming_sortformer_experiment.py",
|
||||
]
|
||||
|
||||
[[tool.uv.dependency-metadata]]
|
||||
# Метаданные wheel-файлов sherpa расходятся, поэтому зависимость от core задана для uv явно.
|
||||
# Версии sherpa, override и core меняются вместе; проверка: uv tree --locked --package sherpa-onnx
|
||||
name = "sherpa-onnx"
|
||||
version = "1.13.5"
|
||||
requires-dist = ["sherpa-onnx-core==1.13.5"]
|
||||
version = "1.13.6"
|
||||
requires-dist = ["sherpa-onnx-core==1.13.6"]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,8 @@
|
||||
"""Точка входа изолированного эксперимента Streaming Sortformer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from local_transcriber.streaming_sortformer_experiment import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,12 +1,14 @@
|
||||
"""
|
||||
Preload CUDA-библиотек из pip-пакетов до импорта ctranslate2.
|
||||
Подготовка CUDA-библиотек из extra перед созданием CUDA-модели.
|
||||
|
||||
Проблема: ctranslate2 на Linux делает dlopen("libcublas.so.12"),
|
||||
но не знает, что библиотека лежит внутри pip-пакета nvidia-cublas-cu12.
|
||||
На Windows ctranslate2 решает это сам через os.add_dll_directory.
|
||||
На Windows ctranslate2 не добавляет nvidia/cublas/bin в поиск DLL.
|
||||
Регистрируем каталог и загружаем cuBLAS Lt, затем cuBLAS по полному пути.
|
||||
Храним DLL и регистрацию каталога до завершения процесса.
|
||||
|
||||
Решение: загружаем libcublas.so.12 по полному пути через ctypes.CDLL
|
||||
с флагом RTLD_GLOBAL до первого import ctranslate2. Динамический линкер
|
||||
с флагом RTLD_GLOBAL до создания CUDA-модели. Динамический линкер
|
||||
кеширует загруженные библиотеки по soname — когда ctranslate2 потом
|
||||
вызовет dlopen("libcublas.so.12"), линкер вернёт уже загруженный handle.
|
||||
|
||||
@@ -21,20 +23,24 @@ import glob
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Закрытие регистрации удаляет каталог из поиска DLL, поэтому храним handles.
|
||||
_dll_directories: dict[str, object] = {}
|
||||
_dll_libraries: dict[str, object] = {}
|
||||
|
||||
|
||||
def ensure_cublas_loadable() -> None:
|
||||
"""Загружает libcublas из nvidia-cublas-cu12 в адресное пространство процесса.
|
||||
"""Загружает cuBLAS из nvidia-cublas-cu12 в адресное пространство процесса.
|
||||
|
||||
Вызывать ДО первого import ctranslate2.
|
||||
Вызывать перед созданием CUDA-модели; CPU-путь не требует bootstrap.
|
||||
Безопасно вызывать многократно и на платформах без nvidia-cublas-cu12.
|
||||
"""
|
||||
if sys.platform != "linux":
|
||||
if sys.platform not in ("linux", "win32"):
|
||||
return
|
||||
|
||||
try:
|
||||
import nvidia.cublas # type: ignore[import-untyped]
|
||||
except ImportError:
|
||||
# nvidia-cublas-cu12 не установлен (Windows, macOS, или CPU-only setup)
|
||||
# Optional-пакет не установлен; системная cuBLAS всё ещё может работать.
|
||||
return
|
||||
|
||||
# nvidia.cublas может быть namespace package (__file__ == None),
|
||||
@@ -42,6 +48,21 @@ def ensure_cublas_loadable() -> None:
|
||||
cublas_paths = getattr(nvidia.cublas, "__path__", None)
|
||||
if not cublas_paths:
|
||||
return
|
||||
if sys.platform == "win32":
|
||||
for package_path in cublas_paths:
|
||||
bin_dir = os.path.join(package_path, "bin")
|
||||
if not os.path.isdir(bin_dir):
|
||||
continue
|
||||
if bin_dir not in _dll_directories:
|
||||
_dll_directories[bin_dir] = os.add_dll_directory(bin_dir)
|
||||
# cuBLAS зависит от Lt; явный preload также обслуживает LoadLibrary
|
||||
# внутри CTranslate2. Порядок здесь существенен.
|
||||
for filename in ("cublasLt64_12.dll", "cublas64_12.dll"):
|
||||
dll_path = os.path.join(bin_dir, filename)
|
||||
if dll_path not in _dll_libraries:
|
||||
_dll_libraries[dll_path] = ctypes.WinDLL(dll_path)
|
||||
return
|
||||
return
|
||||
cublas_lib_dir = os.path.join(cublas_paths[0], "lib")
|
||||
if not os.path.isdir(cublas_lib_dir):
|
||||
return
|
||||
|
||||
@@ -16,6 +16,9 @@ class Backend(Protocol):
|
||||
наследование не требуется.
|
||||
"""
|
||||
|
||||
engine: str
|
||||
"""Имя движка для сведений о выполнении."""
|
||||
|
||||
@property
|
||||
def word_timestamps_available(self) -> bool:
|
||||
"""Гарантирует ли выбранный backend/model пословные таймкоды."""
|
||||
@@ -53,3 +56,7 @@ class Backend(Protocol):
|
||||
) -> TranscribeResult:
|
||||
"""Транскрибирует файл, возвращает результат."""
|
||||
...
|
||||
|
||||
def runtime_info(self) -> dict[str, str]:
|
||||
"""Версии runtime и фактическая конфигурация для удалённой диагностики."""
|
||||
...
|
||||
@@ -6,21 +6,17 @@ from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# CUDA bootstrap — должен быть ДО импорта faster_whisper / ctranslate2
|
||||
from huggingface_hub import snapshot_download
|
||||
from huggingface_hub.errors import LocalEntryNotFoundError
|
||||
|
||||
from local_transcriber._cuda_bootstrap import ensure_cublas_loadable
|
||||
|
||||
ensure_cublas_loadable()
|
||||
|
||||
from faster_whisper import WhisperModel # noqa: E402
|
||||
from huggingface_hub import snapshot_download # noqa: E402
|
||||
from huggingface_hub.errors import LocalEntryNotFoundError # noqa: E402
|
||||
|
||||
from local_transcriber.types import ( # noqa: E402
|
||||
from local_transcriber.types import (
|
||||
Segment,
|
||||
TranscribeResult,
|
||||
Word,
|
||||
WordTimestampsUnavailableError,
|
||||
)
|
||||
from local_transcriber.utils import package_version
|
||||
|
||||
MODEL_REPOS = {
|
||||
"tiny": "Systran/faster-whisper-tiny",
|
||||
@@ -48,10 +44,12 @@ MODEL_REQUIRED_FILES = [
|
||||
class FasterWhisperBackend:
|
||||
"""Бэкенд транскрипции через faster-whisper (CTranslate2)."""
|
||||
|
||||
engine = "faster-whisper"
|
||||
word_timestamps_available = True
|
||||
|
||||
def __init__(self):
|
||||
self.actual_compute_type: str | None = None
|
||||
self._cpu_threads = 0
|
||||
|
||||
def ensure_model_available(
|
||||
self,
|
||||
@@ -94,6 +92,12 @@ class FasterWhisperBackend:
|
||||
|
||||
cpu_threads: число потоков для CPU inference (0 = дефолт библиотеки, обычно 4).
|
||||
"""
|
||||
if device == "cuda":
|
||||
ensure_cublas_loadable()
|
||||
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
self._cpu_threads = cpu_threads
|
||||
try:
|
||||
return WhisperModel(
|
||||
model_path,
|
||||
@@ -157,6 +161,22 @@ class FasterWhisperBackend:
|
||||
words=words,
|
||||
)
|
||||
|
||||
def runtime_info(self) -> dict[str, str]:
|
||||
"""Версии CTranslate2/faster-whisper и число видимых CUDA-устройств."""
|
||||
import ctranslate2
|
||||
|
||||
try:
|
||||
cuda_devices = str(ctranslate2.get_cuda_device_count())
|
||||
except Exception as exc: # noqa: BLE001 — диагностика не должна ронять запуск
|
||||
cuda_devices = f"недоступно ({exc})"
|
||||
return {
|
||||
"faster_whisper": package_version("faster-whisper"),
|
||||
"ctranslate2": package_version("ctranslate2"),
|
||||
"cuda_devices": cuda_devices,
|
||||
"compute_type": self.actual_compute_type or "",
|
||||
"cpu_threads": str(self._cpu_threads) if self._cpu_threads else "по умолчанию",
|
||||
}
|
||||
|
||||
|
||||
def _notify(on_status: Callable[[str], None] | None, message: str) -> None:
|
||||
if on_status is not None:
|
||||
|
||||
@@ -15,6 +15,7 @@ from local_transcriber.types import (
|
||||
Word,
|
||||
WordTimestampsUnavailableError,
|
||||
)
|
||||
from local_transcriber.utils import package_version
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -131,6 +132,8 @@ def _normalize_quantization(compute_type: str) -> str | None:
|
||||
class OnnxAsrBackend:
|
||||
"""Бэкенд транскрипции через onnx-asr (ONNX Runtime)."""
|
||||
|
||||
engine = "onnx-asr"
|
||||
|
||||
def __init__(self, compute_type_explicit: bool = True):
|
||||
self._compute_type_explicit = compute_type_explicit
|
||||
self.actual_compute_type: str | None = None
|
||||
@@ -138,6 +141,9 @@ class OnnxAsrBackend:
|
||||
self._model_name: str | None = None
|
||||
self._model_spec: OnnxModelSpec | None = None
|
||||
self._vad: Any = None
|
||||
self._providers: list[str] = []
|
||||
self._quantization: str | None = None
|
||||
self._cpu_threads = 0
|
||||
|
||||
@property
|
||||
def word_timestamps_available(self) -> bool:
|
||||
@@ -191,20 +197,49 @@ class OnnxAsrBackend:
|
||||
|
||||
compute_type маппится в onnx-asr ``quantization`` — это суффикс файла
|
||||
модели; для unquantized (float32/fp32) нужно None, не строку.
|
||||
cpu_threads > 0 задаёт intra_op_num_threads сессиям ASR и VAD;
|
||||
0 оставляет настройки потоков onnxruntime.
|
||||
"""
|
||||
import onnx_asr
|
||||
|
||||
actual_compute_type = self.actual_compute_type or compute_type
|
||||
quantization = _normalize_quantization(actual_compute_type)
|
||||
|
||||
providers = ["CPUExecutionProvider"]
|
||||
session_kwargs: dict[str, Any] = {"providers": providers}
|
||||
if cpu_threads > 0:
|
||||
session_kwargs["sess_options"] = _session_options(cpu_threads)
|
||||
model = onnx_asr.load_model(
|
||||
model=model_path,
|
||||
quantization=quantization,
|
||||
**session_kwargs,
|
||||
)
|
||||
vad = onnx_asr.load_vad("silero")
|
||||
vad = onnx_asr.load_vad("silero", **session_kwargs)
|
||||
self._vad = vad
|
||||
self._providers = providers
|
||||
self._quantization = quantization
|
||||
self._cpu_threads = cpu_threads
|
||||
return model.with_vad(vad).with_timestamps()
|
||||
|
||||
def runtime_info(self) -> dict[str, str]:
|
||||
"""Версии ONNX Runtime/onnx-asr и providers, заданные сессиям ASR и VAD.
|
||||
|
||||
Доступность provider в wheel не означает, что граф выполняется на нём:
|
||||
сессии получают только явно заданный список.
|
||||
"""
|
||||
import onnxruntime
|
||||
|
||||
configured = ", ".join(self._providers)
|
||||
return {
|
||||
"onnxruntime": package_version("onnxruntime"),
|
||||
"onnx_asr": package_version("onnx-asr"),
|
||||
"available_providers": ", ".join(onnxruntime.get_available_providers()),
|
||||
"asr_providers": configured,
|
||||
"vad_providers": configured,
|
||||
"quantization": self._quantization or "float32",
|
||||
"intra_op_threads": str(self._cpu_threads) if self._cpu_threads else "по умолчанию",
|
||||
}
|
||||
|
||||
def transcribe(
|
||||
self,
|
||||
model: Any,
|
||||
@@ -278,7 +313,7 @@ class OnnxAsrBackend:
|
||||
fallback = _whisper_fallback_model(model_name)
|
||||
raise ValueError(
|
||||
f"Модель '{model_name}' относится к Whisper и не поддерживается "
|
||||
"ONNX-бэкендом. Без CUDA --device auto выбирает ONNX; "
|
||||
"ONNX-бэкендом. --device auto всегда выбирает ONNX CPU; "
|
||||
f"укажите --device openvino-cpu --model {model_name} на x86, "
|
||||
f"--device cpu --model {fallback} на любой платформе "
|
||||
f"или --device cuda --model {fallback} при NVIDIA GPU."
|
||||
@@ -313,6 +348,15 @@ class OnnxAsrBackend:
|
||||
)
|
||||
|
||||
|
||||
def _session_options(cpu_threads: int) -> Any:
|
||||
"""SessionOptions с бюджетом потоков; общий объект для ASR и VAD."""
|
||||
import onnxruntime
|
||||
|
||||
options = onnxruntime.SessionOptions()
|
||||
options.intra_op_num_threads = cpu_threads
|
||||
return options
|
||||
|
||||
|
||||
def _format_compute_types(quantizations: frozenset[str | None]) -> str:
|
||||
values = [_compute_type_for_quantization(value) for value in quantizations]
|
||||
return ", ".join(sorted(values))
|
||||
|
||||
@@ -19,6 +19,7 @@ from local_transcriber.types import (
|
||||
Word,
|
||||
WordTimestampsUnavailableError,
|
||||
)
|
||||
from local_transcriber.utils import package_version
|
||||
|
||||
# (model_alias, compute_type) → HF repo
|
||||
MODEL_REPOS: dict[tuple[str, str], str] = {
|
||||
@@ -56,6 +57,7 @@ MODEL_REQUIRED_FILES = [
|
||||
class OpenVINOBackend:
|
||||
"""Бэкенд транскрипции через openvino-genai WhisperPipeline."""
|
||||
|
||||
engine = "openvino"
|
||||
word_timestamps_available = True
|
||||
|
||||
def __init__(
|
||||
@@ -203,6 +205,18 @@ class OpenVINOBackend:
|
||||
words=words,
|
||||
)
|
||||
|
||||
def runtime_info(self) -> dict[str, str]:
|
||||
"""Версия OpenVINO, доступные устройства и выбранное фактически."""
|
||||
from openvino import Core
|
||||
|
||||
return {
|
||||
"openvino": package_version("openvino"),
|
||||
"available_devices": ", ".join(Core().available_devices),
|
||||
"device": self.actual_ov_device or "",
|
||||
"compute_type": self.actual_compute_type or "",
|
||||
"threads": "управляет OpenVINO, --threads не применяется",
|
||||
}
|
||||
|
||||
def _resolve_repo(self, model_name: str, compute_type: str) -> tuple[str, str]:
|
||||
"""Находит HF repo для пары (model, compute_type) с fallback.
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+133
-190
@@ -2,13 +2,20 @@
|
||||
|
||||
import sys
|
||||
import time
|
||||
import warnings
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.status import Status
|
||||
|
||||
from .config import apply_device_defaults, load_config, resolve_defaults
|
||||
from .config import (
|
||||
CliValues,
|
||||
load_config,
|
||||
resolve_defaults,
|
||||
)
|
||||
from .context_menu import install_menu as install_context_menu
|
||||
from .context_menu import uninstall_menu as uninstall_context_menu
|
||||
from .diarization import build_speaker_transcript
|
||||
@@ -30,11 +37,12 @@ from .quality import (
|
||||
)
|
||||
from .speaker_diarizer import SpeakerDiarizer, load_speaker_diarizer
|
||||
from .transcriber import (
|
||||
ExecutionInfo,
|
||||
ExecutionRequest,
|
||||
Segment,
|
||||
Transcriber,
|
||||
TranscribeResult,
|
||||
_is_cuda_error,
|
||||
_transcribe_file,
|
||||
load_model,
|
||||
cuda_error_hint,
|
||||
)
|
||||
from .types import (
|
||||
UNKNOWN_LANGUAGE,
|
||||
@@ -44,10 +52,7 @@ from .types import (
|
||||
)
|
||||
from .utils import (
|
||||
build_output_path,
|
||||
detect_device,
|
||||
expand_globs,
|
||||
get_gpu_name,
|
||||
get_intel_gpu_name,
|
||||
has_existing_transcript,
|
||||
validate_input_file,
|
||||
)
|
||||
@@ -56,17 +61,34 @@ app = typer.Typer()
|
||||
console = Console(stderr=True)
|
||||
|
||||
|
||||
def _format_device_info(device_used: str) -> str:
|
||||
"""Формирует строку устройства для шапки транскрипта."""
|
||||
if device_used == "cuda":
|
||||
gpu_name = get_gpu_name()
|
||||
return f"CUDA ({gpu_name or 'Unknown GPU'})"
|
||||
if device_used == "openvino-gpu":
|
||||
gpu_name = get_intel_gpu_name()
|
||||
return f"OpenVINO ({gpu_name or 'Intel GPU'})"
|
||||
if device_used in ("openvino", "openvino-cpu"):
|
||||
return "OpenVINO (CPU)"
|
||||
return "CPU"
|
||||
def _print_cuda_hint(exc: BaseException, device: str | None = None) -> None:
|
||||
"""Показывает подсказку, не заменяя исходную ошибку."""
|
||||
hint = cuda_error_hint(exc, device=device)
|
||||
if hint:
|
||||
console.print(hint, style="yellow", markup=False)
|
||||
|
||||
|
||||
def _show_cli_warning(
|
||||
message: Warning | str,
|
||||
*_args: object,
|
||||
**_kwargs: object,
|
||||
) -> None:
|
||||
"""Печатает предупреждение в пользовательском формате CLI."""
|
||||
console.print(f"Внимание: {message}", style="yellow", soft_wrap=True, markup=False)
|
||||
|
||||
|
||||
def _with_cli_warning_renderer(
|
||||
command: Callable[..., None],
|
||||
) -> Callable[..., None]:
|
||||
"""Устанавливает CLI-рендер предупреждений на время одного запуска."""
|
||||
|
||||
@wraps(command)
|
||||
def wrapped(*args: object, **kwargs: object) -> None:
|
||||
with warnings.catch_warnings():
|
||||
warnings.showwarning = _show_cli_warning
|
||||
command(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def _format_language_mode(requested_language: str, result: TranscribeResult) -> str:
|
||||
@@ -193,6 +215,7 @@ def _print_diarization_report(
|
||||
|
||||
|
||||
@app.command()
|
||||
@_with_cli_warning_renderer
|
||||
def main(
|
||||
files: list[Path] | None = typer.Argument(None, help="Пути к аудио/видеофайлам"),
|
||||
model: str | None = typer.Option(
|
||||
@@ -232,9 +255,9 @@ def main(
|
||||
min=0,
|
||||
help="Потоки CPU (0 = дефолт библиотеки; рекомендуется = число физ. ядер)",
|
||||
),
|
||||
diarize: bool = typer.Option(
|
||||
False,
|
||||
"--diarize",
|
||||
diarize: bool | None = typer.Option(
|
||||
None,
|
||||
"--diarize/--no-diarize",
|
||||
help="Разделить транскрипт на реплики говорящих",
|
||||
),
|
||||
speakers: int | None = typer.Option(
|
||||
@@ -299,20 +322,36 @@ def main(
|
||||
)
|
||||
raise SystemExit(2)
|
||||
|
||||
if diarize is False and speakers is not None:
|
||||
console.print(
|
||||
"--no-diarize и --speakers несовместимы.",
|
||||
style="red bold",
|
||||
)
|
||||
raise SystemExit(2)
|
||||
|
||||
requested_device: str | None = None
|
||||
try:
|
||||
config = load_config()
|
||||
cli_values = {
|
||||
cli_values: CliValues = {
|
||||
"model": model,
|
||||
"language": language,
|
||||
"device": device,
|
||||
"compute_type": compute_type,
|
||||
"diarize": diarize,
|
||||
}
|
||||
defaults = resolve_defaults(cli_values, config)
|
||||
diarize_enabled = defaults["diarize"] is True or speakers is not None
|
||||
requested_device = defaults["device"]
|
||||
|
||||
resolved_device = detect_device(defaults["device"])
|
||||
defaults = apply_device_defaults(defaults, resolved_device, cli_values, config)
|
||||
|
||||
ct_explicit = compute_type is not None or "compute_type" in config
|
||||
# Умолчания model/compute_type зависят от устройства — их разрешает module.
|
||||
request = ExecutionRequest(
|
||||
device=requested_device,
|
||||
model=defaults["model"],
|
||||
compute_type=defaults["compute_type"],
|
||||
language=defaults["language"],
|
||||
cpu_threads=threads,
|
||||
require_word_timestamps=diarize_enabled,
|
||||
)
|
||||
|
||||
expanded = expand_globs(files)
|
||||
if not expanded:
|
||||
@@ -329,23 +368,19 @@ def main(
|
||||
if is_batch:
|
||||
_run_batch(
|
||||
expanded,
|
||||
defaults,
|
||||
request,
|
||||
verbose,
|
||||
force,
|
||||
ct_explicit,
|
||||
cpu_threads=threads,
|
||||
diarize=diarize or speakers is not None,
|
||||
diarize=diarize_enabled,
|
||||
speakers=speakers,
|
||||
)
|
||||
else:
|
||||
_run_single(
|
||||
expanded[0],
|
||||
defaults,
|
||||
request,
|
||||
output,
|
||||
verbose,
|
||||
ct_explicit,
|
||||
cpu_threads=threads,
|
||||
diarize=diarize or speakers is not None,
|
||||
diarize=diarize_enabled,
|
||||
speakers=speakers,
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
@@ -354,21 +389,15 @@ def main(
|
||||
except SystemExit:
|
||||
raise
|
||||
except ValueError as exc:
|
||||
_print_cuda_hint(exc, requested_device)
|
||||
console.print(f"Ошибка: {exc}", style="red bold")
|
||||
raise SystemExit(1)
|
||||
except (FileNotFoundError,) as exc:
|
||||
_print_cuda_hint(exc, requested_device)
|
||||
console.print(f"Ошибка: {exc}", style="red bold")
|
||||
raise SystemExit(1)
|
||||
except Exception as exc:
|
||||
if _is_cuda_error(exc) and sys.platform == "win32":
|
||||
console.print(
|
||||
"GPU на Windows требует CUDA toolkit (включает cuBLAS).\n"
|
||||
"Установите одним из способов:\n"
|
||||
" choco install cuda\n"
|
||||
" winget install -e --id Nvidia.CUDA\n"
|
||||
"После установки перезапустите терминал.",
|
||||
style="yellow",
|
||||
)
|
||||
_print_cuda_hint(exc, requested_device)
|
||||
if verbose:
|
||||
console.print_exception()
|
||||
else:
|
||||
@@ -377,13 +406,50 @@ def main(
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def _print_execution_header(info: ExecutionInfo, verbose: bool) -> None:
|
||||
"""Печатает выбранное исполнение; в --verbose — версии runtime для диагностики."""
|
||||
console.print(
|
||||
f"Модель: [bold]{info.model}[/bold] "
|
||||
f"Устройство: [bold]{info.device}[/bold] "
|
||||
f"Compute: [bold]{info.compute_type}[/bold]"
|
||||
)
|
||||
if info.device == "openvino-gpu" and info.model != "large-v3":
|
||||
console.print(
|
||||
"Совет: --model large-v3 даёт лучшее качество на GPU (~2x дольше)",
|
||||
style="dim",
|
||||
)
|
||||
if info.device != info.resolved_device:
|
||||
console.print(
|
||||
f"Запрошено {info.requested_device}, используется {info.device}",
|
||||
style="yellow",
|
||||
)
|
||||
if verbose:
|
||||
threads = info.cpu_threads or "по умолчанию библиотеки"
|
||||
console.print(
|
||||
f"Движок: {info.engine} Потоки (запрошено): {threads}", style="dim"
|
||||
)
|
||||
for key, value in info.runtime.items():
|
||||
console.print(f" {key}: {value}", style="dim", markup=False)
|
||||
|
||||
|
||||
def _load_diarizer(
|
||||
diarize: bool, speakers: int | None, cpu_threads: int
|
||||
) -> SpeakerDiarizer | None:
|
||||
"""Готовит диаризатор до первого ASR; пословный контракт уже проверен module."""
|
||||
if not diarize:
|
||||
return None
|
||||
return load_speaker_diarizer(
|
||||
speakers=speakers,
|
||||
threads=cpu_threads,
|
||||
on_status=lambda message: console.print(message),
|
||||
)
|
||||
|
||||
|
||||
def _run_single(
|
||||
file: Path,
|
||||
defaults: dict[str, str],
|
||||
request: ExecutionRequest,
|
||||
output: Path | None,
|
||||
verbose: bool,
|
||||
compute_type_explicit: bool = False,
|
||||
cpu_threads: int = 0,
|
||||
diarize: bool = False,
|
||||
speakers: int | None = None,
|
||||
) -> None:
|
||||
@@ -391,9 +457,6 @@ def _run_single(
|
||||
start = time.monotonic()
|
||||
|
||||
validated_file = validate_input_file(file)
|
||||
requested_device = defaults["device"]
|
||||
resolved_device = detect_device(requested_device)
|
||||
strict = requested_device != "auto"
|
||||
output_path = build_output_path(validated_file, output)
|
||||
|
||||
console.print(f"Файл: [bold]{validated_file.name}[/bold]")
|
||||
@@ -401,59 +464,18 @@ def _run_single(
|
||||
def on_segment(seg: Segment) -> None:
|
||||
console.print(f" [{seg.start:.2f}s] {seg.text.strip()}")
|
||||
|
||||
model_obj, actual_device, backend, model_path = load_model(
|
||||
defaults["model"],
|
||||
resolved_device,
|
||||
defaults["compute_type"],
|
||||
on_status=lambda msg: console.print(msg),
|
||||
strict_device=strict,
|
||||
compute_type_explicit=compute_type_explicit,
|
||||
cpu_threads=cpu_threads,
|
||||
)
|
||||
actual_ct = (
|
||||
getattr(backend, "actual_compute_type", defaults["compute_type"])
|
||||
or defaults["compute_type"]
|
||||
)
|
||||
console.print(
|
||||
f"Модель: [bold]{defaults['model']}[/bold] "
|
||||
f"Устройство: [bold]{actual_device}[/bold] "
|
||||
f"Compute: [bold]{actual_ct}[/bold]"
|
||||
)
|
||||
if actual_device == "openvino-gpu" and defaults["model"] != "large-v3":
|
||||
console.print(
|
||||
"Совет: --model large-v3 даёт лучшее качество на GPU (~2x дольше)",
|
||||
style="dim",
|
||||
)
|
||||
|
||||
speaker_diarizer = None
|
||||
if diarize:
|
||||
if not backend.word_timestamps_available:
|
||||
raise ValueError(
|
||||
"Выбранный движок или модель не поддерживает пословные таймкоды"
|
||||
)
|
||||
speaker_diarizer = load_speaker_diarizer(
|
||||
speakers=speakers,
|
||||
threads=cpu_threads,
|
||||
on_status=lambda message: console.print(message),
|
||||
)
|
||||
transcriber = Transcriber(request)
|
||||
info = transcriber.prepare(on_status=lambda msg: console.print(msg))
|
||||
_print_execution_header(info, verbose)
|
||||
speaker_diarizer = _load_diarizer(diarize, speakers, request.cpu_threads)
|
||||
|
||||
with Status("Подготавливаю запуск...", console=console) as status:
|
||||
tfr = _transcribe_file(
|
||||
model=model_obj,
|
||||
actual_device=actual_device,
|
||||
backend=backend,
|
||||
model_path=model_path,
|
||||
file_path=validated_file,
|
||||
model_name=defaults["model"],
|
||||
compute_type=defaults["compute_type"],
|
||||
language=defaults["language"] if defaults["language"] != "auto" else None,
|
||||
result = transcriber.transcribe(
|
||||
validated_file,
|
||||
on_segment=on_segment if verbose else None,
|
||||
on_status=status.update,
|
||||
strict_device=strict,
|
||||
cpu_threads=cpu_threads,
|
||||
)
|
||||
|
||||
result = tfr.result
|
||||
speaker_transcript = None
|
||||
diarization_warning = None
|
||||
diarization_degraded = False
|
||||
@@ -480,33 +502,19 @@ def _run_single(
|
||||
if diarization_warning is not None:
|
||||
console.print(f"Внимание: {diarization_warning}", style="yellow")
|
||||
|
||||
if tfr.actual_device != resolved_device:
|
||||
if requested_device == "auto":
|
||||
console.print(
|
||||
f"Определено устройство {resolved_device}, "
|
||||
f"но использовано {tfr.actual_device} (fallback)",
|
||||
style="yellow",
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
f"Запрошено {requested_device}, использовано {tfr.actual_device}",
|
||||
style="yellow",
|
||||
)
|
||||
|
||||
if len(result.segments) == 0:
|
||||
message = f"Речь не обнаружена в файле {validated_file.name}"
|
||||
if speaker_diarizer is not None:
|
||||
message += "; диаризация не запускалась"
|
||||
console.print(message, style="yellow")
|
||||
|
||||
device_info = _format_device_info(result.device_used)
|
||||
language_mode = _format_language_mode(defaults["language"], result)
|
||||
language_mode = _format_language_mode(request.language or "auto", result)
|
||||
|
||||
content = format_transcript(
|
||||
result=result,
|
||||
source_filename=validated_file.name,
|
||||
model_name=defaults["model"],
|
||||
device_info=device_info,
|
||||
model_name=info.model,
|
||||
device_info=info.description,
|
||||
language_mode=language_mode,
|
||||
speaker_transcript=speaker_transcript,
|
||||
diarization_warning=diarization_warning,
|
||||
@@ -523,11 +531,9 @@ def _run_single(
|
||||
|
||||
def _run_batch(
|
||||
files: list[Path],
|
||||
defaults: dict[str, str],
|
||||
request: ExecutionRequest,
|
||||
verbose: bool,
|
||||
force: bool,
|
||||
compute_type_explicit: bool = False,
|
||||
cpu_threads: int = 0,
|
||||
diarize: bool = False,
|
||||
speakers: int | None = None,
|
||||
) -> None:
|
||||
@@ -557,50 +563,11 @@ def _run_batch(
|
||||
raise SystemExit(1)
|
||||
return
|
||||
|
||||
# Phase 2: Load model (ensure + create в одном вызове)
|
||||
requested_device = defaults["device"]
|
||||
resolved_device = detect_device(requested_device)
|
||||
strict = requested_device != "auto"
|
||||
model_obj, actual_device, backend, model_path = load_model(
|
||||
defaults["model"],
|
||||
resolved_device,
|
||||
defaults["compute_type"],
|
||||
on_status=lambda msg: console.print(msg),
|
||||
strict_device=strict,
|
||||
compute_type_explicit=compute_type_explicit,
|
||||
cpu_threads=cpu_threads,
|
||||
)
|
||||
|
||||
if actual_device == "openvino-gpu" and defaults["model"] != "large-v3":
|
||||
console.print(
|
||||
"Совет: --model large-v3 даёт лучшее качество на GPU (~2x дольше)",
|
||||
style="dim",
|
||||
)
|
||||
|
||||
if actual_device != resolved_device:
|
||||
if requested_device == "auto":
|
||||
console.print(
|
||||
f"Определено устройство {resolved_device}, "
|
||||
f"но используется {actual_device} (fallback)",
|
||||
style="yellow",
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
f"Запрошено {requested_device}, используется {actual_device}",
|
||||
style="yellow",
|
||||
)
|
||||
|
||||
speaker_diarizer = None
|
||||
if diarize:
|
||||
if not backend.word_timestamps_available:
|
||||
raise ValueError(
|
||||
"Выбранный движок или модель не поддерживает пословные таймкоды"
|
||||
)
|
||||
speaker_diarizer = load_speaker_diarizer(
|
||||
speakers=speakers,
|
||||
threads=cpu_threads,
|
||||
on_status=lambda message: console.print(message),
|
||||
)
|
||||
# Phase 2: module выполнения создаётся, когда есть работа
|
||||
transcriber = Transcriber(request)
|
||||
info = transcriber.prepare(on_status=lambda msg: console.print(msg))
|
||||
_print_execution_header(info, verbose)
|
||||
speaker_diarizer = _load_diarizer(diarize, speakers, request.cpu_threads)
|
||||
|
||||
# Phase 3: Transcribe
|
||||
processed = 0
|
||||
@@ -618,38 +585,15 @@ def _run_batch(
|
||||
console.print(f" [{seg.start:.2f}s] {seg.text.strip()}")
|
||||
|
||||
with Status(f"{prefix}...", console=console) as status:
|
||||
tfr = _transcribe_file(
|
||||
model=model_obj,
|
||||
actual_device=actual_device,
|
||||
backend=backend,
|
||||
model_path=model_path,
|
||||
file_path=file,
|
||||
model_name=defaults["model"],
|
||||
compute_type=defaults["compute_type"],
|
||||
language=defaults["language"]
|
||||
if defaults["language"] != "auto"
|
||||
else None,
|
||||
result = transcriber.transcribe(
|
||||
file,
|
||||
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:
|
||||
console.print(
|
||||
f" {file.name}: fallback на {tfr.actual_device} при транскрипции",
|
||||
style="yellow",
|
||||
)
|
||||
# Обновляем после возможного mid-stream fallback
|
||||
model_obj = tfr.model
|
||||
actual_device = tfr.actual_device
|
||||
backend = tfr.backend
|
||||
model_path = tfr.model_path
|
||||
|
||||
result = tfr.result
|
||||
language_mode = _format_language_mode(defaults["language"], result)
|
||||
language_mode = _format_language_mode(request.language or "auto", result)
|
||||
speaker_transcript = None
|
||||
diarization_warning = None
|
||||
file_degraded = False
|
||||
@@ -689,13 +633,11 @@ def _run_batch(
|
||||
message += "; диаризация не запускалась"
|
||||
console.print(message, style="yellow")
|
||||
|
||||
device_info = _format_device_info(result.device_used)
|
||||
|
||||
content = format_transcript(
|
||||
result=result,
|
||||
source_filename=file.name,
|
||||
model_name=defaults["model"],
|
||||
device_info=device_info,
|
||||
model_name=info.model,
|
||||
device_info=info.description,
|
||||
language_mode=language_mode,
|
||||
speaker_transcript=speaker_transcript,
|
||||
diarization_warning=diarization_warning,
|
||||
@@ -714,6 +656,7 @@ def _run_batch(
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_print_cuda_hint(exc, request.device)
|
||||
if verbose:
|
||||
console.print_exception()
|
||||
else:
|
||||
|
||||
@@ -3,14 +3,56 @@
|
||||
import tomllib
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from typing import TypedDict, TypeVar, cast
|
||||
|
||||
HARDCODED_DEFAULTS: dict[str, str] = {
|
||||
type ConfigValue = str | bool
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
class ConfigValues(TypedDict, total=False):
|
||||
"""Проверенные значения из TOML-конфига."""
|
||||
|
||||
model: str
|
||||
language: str
|
||||
device: str
|
||||
compute_type: str
|
||||
diarize: bool
|
||||
|
||||
|
||||
class CliValues(TypedDict, total=False):
|
||||
"""Явные значения CLI до разрешения каскада."""
|
||||
|
||||
model: str | None
|
||||
language: str | None
|
||||
device: str | None
|
||||
compute_type: str | None
|
||||
diarize: bool | None
|
||||
|
||||
|
||||
class ResolvedConfig(TypedDict):
|
||||
"""Конфигурация после каскада CLI > TOML.
|
||||
|
||||
``model`` и ``compute_type`` остаются ``None``, если не заданы явно:
|
||||
их умолчания зависят от устройства и разрешаются при загрузке модели.
|
||||
"""
|
||||
|
||||
model: str | None
|
||||
language: str
|
||||
device: str
|
||||
compute_type: str | None
|
||||
diarize: bool
|
||||
|
||||
|
||||
HARDCODED_DEFAULTS: ResolvedConfig = {
|
||||
"model": "medium",
|
||||
"language": "ru",
|
||||
"device": "auto",
|
||||
"compute_type": "float32",
|
||||
"diarize": False,
|
||||
}
|
||||
|
||||
# Умолчания по устройству; HARDCODED_DEFAULTS — последний резерв для
|
||||
# устройств вне таблицы.
|
||||
DEVICE_DEFAULTS: dict[str, dict[str, str]] = {
|
||||
"cuda": {"model": "medium", "compute_type": "float16"},
|
||||
"cpu": {"model": "medium", "compute_type": "float32"},
|
||||
@@ -46,7 +88,7 @@ def find_config_file() -> Path | None:
|
||||
return None
|
||||
|
||||
|
||||
def load_config(path: Path | None = None) -> dict[str, str]:
|
||||
def load_config(path: Path | None = None) -> ConfigValues:
|
||||
"""Загружает и валидирует TOML-конфиг.
|
||||
|
||||
Неизвестные ключи вызывают предупреждение (а не ошибку) для forward
|
||||
@@ -70,11 +112,19 @@ def load_config(path: Path | None = None) -> dict[str, str]:
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
result: dict[str, str] = {}
|
||||
result: dict[str, ConfigValue] = {}
|
||||
for key in _VALID_KEYS:
|
||||
if key not in data:
|
||||
continue
|
||||
value = data[key]
|
||||
if key == "diarize":
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(
|
||||
f"Значение 'diarize' в {path} должно быть логическим, "
|
||||
f"получено {type(value).__name__}"
|
||||
)
|
||||
result[key] = value
|
||||
continue
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(
|
||||
f"Значение '{key}' в {path} должно быть строкой, получено {type(value).__name__}"
|
||||
@@ -88,40 +138,41 @@ def load_config(path: Path | None = None) -> dict[str, str]:
|
||||
raise ValueError(f"Значение 'language' в {path} не может быть пустым")
|
||||
result[key] = value
|
||||
|
||||
return result
|
||||
return cast(ConfigValues, result)
|
||||
|
||||
|
||||
def resolve_defaults(
|
||||
cli_values: dict[str, str | None], config: dict[str, str]
|
||||
) -> dict[str, str]:
|
||||
"""Каскад приоритетов: CLI > конфиг-файл > hardcoded-дефолты."""
|
||||
result: dict[str, str] = {}
|
||||
for key in HARDCODED_DEFAULTS:
|
||||
cli_val = cli_values.get(key)
|
||||
if cli_val is not None:
|
||||
result[key] = cli_val
|
||||
elif key in config:
|
||||
result[key] = config[key]
|
||||
else:
|
||||
result[key] = HARDCODED_DEFAULTS[key]
|
||||
return result
|
||||
def resolve_defaults(cli_values: CliValues, config: ConfigValues) -> ResolvedConfig:
|
||||
"""Каскад приоритетов: CLI > конфиг-файл > hardcoded-дефолты.
|
||||
|
||||
Device-aware ключи (model, compute_type) без явного значения остаются None.
|
||||
"""
|
||||
return {
|
||||
"model": _resolve_value(cli_values.get("model"), config.get("model"), None),
|
||||
"language": _resolve_value(
|
||||
cli_values.get("language"),
|
||||
config.get("language"),
|
||||
HARDCODED_DEFAULTS["language"],
|
||||
),
|
||||
"device": _resolve_value(
|
||||
cli_values.get("device"),
|
||||
config.get("device"),
|
||||
HARDCODED_DEFAULTS["device"],
|
||||
),
|
||||
"compute_type": _resolve_value(
|
||||
cli_values.get("compute_type"), config.get("compute_type"), None
|
||||
),
|
||||
"diarize": _resolve_value(
|
||||
cli_values.get("diarize"),
|
||||
config.get("diarize"),
|
||||
HARDCODED_DEFAULTS["diarize"],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def apply_device_defaults(
|
||||
defaults: dict[str, str],
|
||||
resolved_device: str,
|
||||
cli_values: dict[str, str | None],
|
||||
config: dict[str, str],
|
||||
) -> dict[str, str]:
|
||||
"""Применяет device-aware дефолты для model и compute_type,
|
||||
если они не были явно заданы через CLI или конфиг."""
|
||||
device_defs = DEVICE_DEFAULTS.get(resolved_device, {})
|
||||
if not device_defs:
|
||||
return defaults
|
||||
|
||||
result = dict(defaults)
|
||||
for key in ("model", "compute_type"):
|
||||
if cli_values.get(key) is None and key not in config:
|
||||
if key in device_defs:
|
||||
result[key] = device_defs[key]
|
||||
return result
|
||||
def _resolve_value(cli_value: _T | None, config_value: _T | None, default: _T) -> _T:
|
||||
"""Выбирает первое явно заданное значение каскада."""
|
||||
if cli_value is not None:
|
||||
return cli_value
|
||||
if config_value is not None:
|
||||
return config_value
|
||||
return default
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -2,170 +2,245 @@
|
||||
|
||||
import warnings
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from local_transcriber.backends import get_backend
|
||||
from local_transcriber.config import DEVICE_DEFAULTS, HARDCODED_DEFAULTS
|
||||
|
||||
# Re-export из types.py для обратной совместимости
|
||||
from local_transcriber.types import ( # noqa: F401
|
||||
from local_transcriber.types import (
|
||||
Segment,
|
||||
TranscribeFileResult,
|
||||
TranscribeResult,
|
||||
WordTimestampsUnavailableError,
|
||||
)
|
||||
from local_transcriber.utils import detect_device, get_gpu_name, get_intel_gpu_name
|
||||
|
||||
|
||||
def load_model(
|
||||
model_name: str,
|
||||
device: str,
|
||||
compute_type: str,
|
||||
on_status: Callable[[str], None] | None = None,
|
||||
strict_device: bool = False,
|
||||
compute_type_explicit: bool = False,
|
||||
cpu_threads: int = 0,
|
||||
) -> tuple[Any, str, Any, str]:
|
||||
"""Загружает модель: ensure + create с fallback.
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionRequest:
|
||||
"""Запрошенные настройки запуска до разрешения умолчаний.
|
||||
|
||||
Возвращает (model, actual_device, backend, model_path).
|
||||
compute_type_explicit: True если пользователь явно указал --compute-type.
|
||||
cpu_threads: число потоков для CPU inference (0 = дефолт библиотеки).
|
||||
``None`` в model/compute_type означает device-aware умолчание;
|
||||
``strict_device=None`` — strict для любого явного device, кроме ``auto``.
|
||||
"""
|
||||
backend = get_backend(device, compute_type_explicit=compute_type_explicit)
|
||||
actual_device = device
|
||||
|
||||
model_path = backend.ensure_model_available(model_name, compute_type, on_status)
|
||||
device: str = "auto"
|
||||
model: str | None = None
|
||||
compute_type: str | None = None
|
||||
language: str | None = None
|
||||
cpu_threads: int = 0
|
||||
strict_device: bool | None = None
|
||||
require_word_timestamps: bool = False
|
||||
|
||||
try:
|
||||
_notify_status(on_status, f"Инициализирую модель на {device}...")
|
||||
model = backend.create_model(
|
||||
model_path, device, compute_type, cpu_threads=cpu_threads
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionInfo:
|
||||
"""Сведения о выполнении: что запрошено, что выбрано и чем выполняется."""
|
||||
|
||||
requested_device: str
|
||||
resolved_device: str # выбор до загрузки; отличается от device после fallback
|
||||
device: str
|
||||
engine: str
|
||||
model: str
|
||||
compute_type: str
|
||||
cpu_threads: int
|
||||
word_timestamps_available: bool
|
||||
description: str # строка исполнения для шапки транскрипта
|
||||
runtime: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
class Transcriber:
|
||||
"""Module выполнения распознавания на один запуск.
|
||||
|
||||
Владеет загруженной моделью, adapter'ом, фактическими настройками
|
||||
и разрешёнными переходами исполнения. Caller передаёт файлы по одному
|
||||
и получает результат; состояние между файлами не переносит.
|
||||
"""
|
||||
|
||||
def __init__(self, request: ExecutionRequest) -> None:
|
||||
self._request = request
|
||||
self._strict = (
|
||||
request.strict_device
|
||||
if request.strict_device is not None
|
||||
else request.device != "auto"
|
||||
)
|
||||
# Резолвим actual_device по реальному OpenVINO device
|
||||
ov_dev = getattr(backend, "actual_ov_device", None)
|
||||
if ov_dev == "GPU" and actual_device != "openvino-gpu":
|
||||
actual_device = "openvino-gpu"
|
||||
elif (
|
||||
ov_dev == "CPU"
|
||||
and actual_device.startswith("openvino")
|
||||
and actual_device != "openvino-cpu"
|
||||
):
|
||||
actual_device = "openvino-cpu"
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
if device != "cpu" and _is_backend_error(exc, device):
|
||||
if strict_device:
|
||||
self._compute_type_explicit = request.compute_type is not None
|
||||
self._resolved_device = detect_device(request.device)
|
||||
self._device = self._resolved_device
|
||||
self._backend: Any = None
|
||||
self._model: Any = None
|
||||
self._info: ExecutionInfo | None = None
|
||||
|
||||
@property
|
||||
def execution(self) -> ExecutionInfo:
|
||||
"""Текущие сведения о выполнении; доступны после prepare()."""
|
||||
if self._info is None:
|
||||
raise RuntimeError("Модель ещё не подготовлена: вызовите prepare()")
|
||||
return self._info
|
||||
|
||||
def prepare(self, on_status: Callable[[str], None] | None = None) -> ExecutionInfo:
|
||||
"""Загружает модель и проверяет возможности до первого файла. Идемпотентно."""
|
||||
if self._info is not None:
|
||||
return self._info
|
||||
try:
|
||||
info = self._load(self._device, on_status)
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
if not self._may_fall_back(exc):
|
||||
raise
|
||||
warnings.warn(
|
||||
f"Не удалось загрузить модель на {device}: {exc}. Переключение на CPU.",
|
||||
f"Не удалось загрузить модель на {self._device}: {exc}. "
|
||||
"Переключение на CPU.",
|
||||
stacklevel=2,
|
||||
)
|
||||
actual_device = "cpu"
|
||||
backend = get_backend("cpu")
|
||||
model_path = backend.ensure_model_available(
|
||||
model_name, compute_type, on_status
|
||||
info = self._load("cpu", on_status)
|
||||
if self._request.require_word_timestamps and not info.word_timestamps_available:
|
||||
raise ValueError(
|
||||
"Выбранный движок или модель не поддерживает пословные таймкоды"
|
||||
)
|
||||
_notify_status(on_status, "Инициализирую модель на cpu...")
|
||||
model = backend.create_model(
|
||||
model_path, "cpu", compute_type, cpu_threads=cpu_threads
|
||||
)
|
||||
else:
|
||||
raise
|
||||
self._info = info
|
||||
return info
|
||||
|
||||
return model, actual_device, backend, model_path
|
||||
|
||||
|
||||
def _transcribe_file(
|
||||
model: Any,
|
||||
actual_device: str,
|
||||
backend: Any,
|
||||
model_path: str,
|
||||
file_path: Path,
|
||||
model_name: str,
|
||||
compute_type: str,
|
||||
language: str | None = None,
|
||||
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
|
||||
|
||||
try:
|
||||
_notify_status(on_status, "Транскрибирую...")
|
||||
result = backend.transcribe(model, file_path, lang_arg, on_segment, on_status)
|
||||
result.device_used = actual_device
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
if actual_device != "cpu" and _is_backend_error(exc, actual_device):
|
||||
if strict_device:
|
||||
def transcribe(
|
||||
self,
|
||||
file_path: Path,
|
||||
on_segment: Callable[[Segment], None] | None = None,
|
||||
on_status: Callable[[str], None] | None = None,
|
||||
) -> TranscribeResult:
|
||||
"""Транскрибирует один файл; при разрешённом fallback перезагружает модель."""
|
||||
self.prepare(on_status)
|
||||
lang_arg = self._request.language or None
|
||||
if lang_arg == "auto":
|
||||
lang_arg = None
|
||||
try:
|
||||
result = self._recognize(file_path, lang_arg, on_segment, on_status)
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
if not self._may_fall_back(exc):
|
||||
raise
|
||||
warnings.warn(
|
||||
f"Ошибка при транскрипции на {actual_device}: {exc}. "
|
||||
f"Ошибка при транскрипции на {self._device}: {exc}. "
|
||||
"Переключение на CPU и повтор.",
|
||||
stacklevel=2,
|
||||
)
|
||||
actual_device = "cpu"
|
||||
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, cpu_threads=cpu_threads
|
||||
)
|
||||
_notify_status(on_status, "Транскрибирую...")
|
||||
result = backend.transcribe(
|
||||
model, file_path, lang_arg, on_segment, on_status
|
||||
)
|
||||
result.device_used = actual_device
|
||||
else:
|
||||
raise
|
||||
self._info = self._load("cpu", on_status)
|
||||
result = self._recognize(file_path, lang_arg, on_segment, on_status)
|
||||
return result
|
||||
|
||||
return TranscribeFileResult(
|
||||
result=result,
|
||||
model=model,
|
||||
actual_device=actual_device,
|
||||
backend=backend,
|
||||
model_path=model_path,
|
||||
)
|
||||
def _recognize(
|
||||
self,
|
||||
file_path: Path,
|
||||
language: str | None,
|
||||
on_segment: Callable[[Segment], None] | None,
|
||||
on_status: Callable[[str], None] | None,
|
||||
) -> TranscribeResult:
|
||||
_notify_status(on_status, "Транскрибирую...")
|
||||
result = self._backend.transcribe(
|
||||
self._model, file_path, language, on_segment, on_status
|
||||
)
|
||||
result.device_used = self._device
|
||||
return result
|
||||
|
||||
def _may_fall_back(self, exc: BaseException) -> bool:
|
||||
"""Разрешённый переход: не strict, не CPU и ошибка самого движка."""
|
||||
if self._strict or self._device == "cpu":
|
||||
return False
|
||||
return _is_backend_error(exc, self._device)
|
||||
|
||||
def _load(
|
||||
self, device: str, on_status: Callable[[str], None] | None
|
||||
) -> ExecutionInfo:
|
||||
"""Загружает модель на device; неявные model/compute_type берутся из его умолчаний."""
|
||||
defaults = DEVICE_DEFAULTS.get(device, {})
|
||||
model_name = self._request.model or defaults.get(
|
||||
"model", HARDCODED_DEFAULTS["model"]
|
||||
)
|
||||
compute_type = self._request.compute_type or defaults.get(
|
||||
"compute_type", HARDCODED_DEFAULTS["compute_type"]
|
||||
)
|
||||
backend = get_backend(device, compute_type_explicit=self._compute_type_explicit)
|
||||
model_path = backend.ensure_model_available(model_name, compute_type, on_status)
|
||||
_notify_status(on_status, f"Инициализирую модель на {device}...")
|
||||
model = backend.create_model(
|
||||
model_path,
|
||||
device,
|
||||
compute_type,
|
||||
cpu_threads=self._request.cpu_threads,
|
||||
)
|
||||
self._backend = backend
|
||||
self._model = model
|
||||
self._device = _refine_openvino_device(device, backend)
|
||||
return ExecutionInfo(
|
||||
requested_device=self._request.device,
|
||||
resolved_device=self._resolved_device,
|
||||
device=self._device,
|
||||
engine=backend.engine,
|
||||
model=model_name,
|
||||
compute_type=getattr(backend, "actual_compute_type", None) or compute_type,
|
||||
cpu_threads=self._request.cpu_threads,
|
||||
word_timestamps_available=bool(backend.word_timestamps_available),
|
||||
description=_describe_device(self._device),
|
||||
runtime=_collect_runtime_info(backend),
|
||||
)
|
||||
|
||||
|
||||
def _collect_runtime_info(backend: Any) -> dict[str, str]:
|
||||
"""Диагностика не должна ронять запуск: сбой попадает в сведения строкой."""
|
||||
try:
|
||||
return dict(backend.runtime_info())
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"diagnostics": f"недоступно ({exc})"}
|
||||
|
||||
|
||||
def _refine_openvino_device(device: str, backend: Any) -> str:
|
||||
"""Заменяет запрошенный openvino-* на устройство, которое реально выбрал OpenVINO."""
|
||||
ov_dev = getattr(backend, "actual_ov_device", None)
|
||||
if ov_dev == "GPU" and device != "openvino-gpu":
|
||||
return "openvino-gpu"
|
||||
if ov_dev == "CPU" and device.startswith("openvino") and device != "openvino-cpu":
|
||||
return "openvino-cpu"
|
||||
return device
|
||||
|
||||
|
||||
def _describe_device(device: str) -> str:
|
||||
"""Строка исполнения для шапки транскрипта; GPU называется только по данным драйвера."""
|
||||
if device == "cuda":
|
||||
return f"CUDA ({get_gpu_name() or 'Unknown GPU'})"
|
||||
if device == "openvino-gpu":
|
||||
return f"OpenVINO ({get_intel_gpu_name() or 'Intel GPU'})"
|
||||
if device == "openvino-cpu":
|
||||
return "OpenVINO (CPU)"
|
||||
if device == "onnx":
|
||||
return "ONNX (CPU)"
|
||||
return "CPU"
|
||||
|
||||
|
||||
def transcribe(
|
||||
file_path: Path,
|
||||
model_name: str = "large-v3",
|
||||
model_name: str | None = None,
|
||||
device: str = "auto",
|
||||
compute_type: str = "int8",
|
||||
compute_type: str | None = None,
|
||||
language: str | None = None,
|
||||
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,
|
||||
"""Публичный путь одного файла через тот же module выполнения, что и CLI.
|
||||
|
||||
``None`` в model_name/compute_type берёт умолчания выбранного исполнения.
|
||||
"""
|
||||
run = Transcriber(
|
||||
ExecutionRequest(
|
||||
device=device,
|
||||
model=model_name,
|
||||
compute_type=compute_type,
|
||||
language=language,
|
||||
cpu_threads=cpu_threads,
|
||||
strict_device=strict_device,
|
||||
)
|
||||
)
|
||||
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
|
||||
return run.transcribe(file_path, on_segment, on_status)
|
||||
|
||||
|
||||
def ensure_model_available(
|
||||
@@ -175,8 +250,6 @@ def ensure_model_available(
|
||||
on_status: Callable[[str], None] | None = None,
|
||||
) -> str:
|
||||
"""Публичный helper: гарантирует наличие модели для указанного бэкенда."""
|
||||
from local_transcriber.config import DEVICE_DEFAULTS, HARDCODED_DEFAULTS
|
||||
|
||||
if compute_type is None:
|
||||
device_defs = DEVICE_DEFAULTS.get(device, {})
|
||||
compute_type = device_defs.get(
|
||||
@@ -190,11 +263,60 @@ def ensure_model_available(
|
||||
|
||||
|
||||
def _is_cuda_error(exc: BaseException) -> bool:
|
||||
"""Проверка CUDA ошибок — используется в cli.py для Windows-диагностики."""
|
||||
"""Проверяет, относится ли ошибка к CUDA-бэкенду."""
|
||||
msg = str(exc).lower()
|
||||
return any(k in msg for k in ("cuda", "cublas", "cudnn", "out of memory"))
|
||||
|
||||
|
||||
def cuda_error_hint(exc: BaseException, *, device: str | None = None) -> str | None:
|
||||
"""Подсказывает действие только для распознанной причины CUDA-ошибки."""
|
||||
msg = str(exc).lower()
|
||||
if device == "cuda" and "requested " in msg and "compute type" in msg and (
|
||||
"do not support efficient" in msg
|
||||
):
|
||||
return (
|
||||
"Тип вычислений несовместим с выбранным GPU/CUDA runtime. "
|
||||
"Установка extra не добавит аппаратную поддержку. Выберите "
|
||||
"поддерживаемый --compute-type или --device onnx / --device cpu."
|
||||
)
|
||||
if any(k in msg for k in (
|
||||
"no kernel image", "invalid device function", "unsupported gpu",
|
||||
"cublas_status_arch_mismatch", "cuda_error_no_binary_for_gpu",
|
||||
)):
|
||||
return (
|
||||
"GPU несовместим с выбранным CUDA runtime. Установка extra не исправит "
|
||||
"аппаратную несовместимость. Используйте --device onnx или --device cpu."
|
||||
)
|
||||
if any(k in msg for k in (
|
||||
"driver version is insufficient", "no cuda-capable device",
|
||||
"cuda error: no device", "cuda_error_no_device", "libcuda.so", "nvcuda.dll",
|
||||
)):
|
||||
return (
|
||||
"CUDA не видит совместимого GPU/драйвера. Проверьте драйвер NVIDIA "
|
||||
"и совместимость устройства с runtime. Extra не устанавливает драйвер."
|
||||
)
|
||||
if any(k in msg for k in ("cublas", "cudnn", "cudart")) and any(
|
||||
k in msg for k in (
|
||||
"not found", "cannot be loaded", "could not load", "could not find",
|
||||
"cannot open shared object", "no such file", "winerror 126",
|
||||
)
|
||||
):
|
||||
return (
|
||||
"Не найдены CUDA-библиотеки или их зависимости. Подключите extra cuda "
|
||||
"в окружении приложения:\n"
|
||||
" Из клона: uv tool install --force '.[cuda]'\n"
|
||||
" Для разработки: uv sync --extra cuda\n"
|
||||
"При установке из Git используйте инструкцию CUDA в README. "
|
||||
"На Windows также нужен Visual C++ Runtime x64."
|
||||
)
|
||||
if "out of memory" in msg and any(k in msg for k in ("cuda", "cublas", "cudnn")):
|
||||
return (
|
||||
"Недостаточно памяти для выбранного CUDA-пути. Выберите меньшую модель "
|
||||
"или явно используйте --device onnx / --device cpu."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _is_backend_error(exc: BaseException, device: str) -> bool:
|
||||
"""Определяет, связана ли ошибка с конкретным бэкендом (а не с пользовательскими данными)."""
|
||||
if isinstance(exc, WordTimestampsUnavailableError):
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
# Единый признак «язык неизвестен» для всех бэкендов
|
||||
UNKNOWN_LANGUAGE = "unknown"
|
||||
@@ -83,14 +82,5 @@ class TranscribeResult:
|
||||
words: list[Word] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranscribeFileResult:
|
||||
result: TranscribeResult
|
||||
model: Any # backend-specific model handle
|
||||
actual_device: str
|
||||
backend: Any = None # backend instance (для переиспользования в батче)
|
||||
model_path: str = "" # путь к модели (меняется при cross-backend fallback)
|
||||
|
||||
|
||||
StatusCallback = Callable[[str], None] | None
|
||||
SegmentCallback = Callable[[Segment], None] | None
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import glob
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import warnings
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from pathlib import Path
|
||||
|
||||
SUPPORTED_EXTENSIONS = {
|
||||
@@ -16,15 +16,13 @@ SUPPORTED_EXTENSIONS = {
|
||||
def detect_device(requested: str = "auto") -> str:
|
||||
"""Определяет устройство для вычислений.
|
||||
|
||||
При ``requested="auto"`` выбирает CUDA при наличии ``nvidia-smi``,
|
||||
иначе ONNX на CPU.
|
||||
При ``requested="auto"`` всегда выбирает ONNX на CPU.
|
||||
CUDA включается только явным выбором пользователя.
|
||||
``"openvino"`` — алиас для авто-детекта внутри OpenVINO
|
||||
(GPU если доступен, иначе CPU).
|
||||
Возвращает всегда конкретное значение (не абстрактный ``"openvino"``).
|
||||
"""
|
||||
if requested == "auto":
|
||||
if shutil.which("nvidia-smi") is not None:
|
||||
return "cuda"
|
||||
return "onnx"
|
||||
if requested == "openvino":
|
||||
if _is_openvino_gpu_available():
|
||||
@@ -144,3 +142,11 @@ def expand_globs(paths: list[Path]) -> list[Path]:
|
||||
def has_existing_transcript(input_path: Path) -> bool:
|
||||
"""Проверяет наличие транскрипта для skip-логики батч-режима."""
|
||||
return build_output_path(input_path).exists()
|
||||
|
||||
|
||||
def package_version(name: str) -> str:
|
||||
"""Версия установленного пакета для диагностики; отсутствие не считается ошибкой."""
|
||||
try:
|
||||
return version(name)
|
||||
except PackageNotFoundError:
|
||||
return "не установлен"
|
||||
@@ -7,6 +7,25 @@ from local_transcriber.backends.faster_whisper import FasterWhisperBackend
|
||||
from local_transcriber.types import Word
|
||||
|
||||
|
||||
@pytest.mark.parametrize("device", ["cpu", "cuda"])
|
||||
def test_bootstrap_only_for_explicit_cuda(monkeypatch, device):
|
||||
"""CPU-модель не загружает optional CUDA-библиотеки."""
|
||||
bootstrap = MagicMock()
|
||||
model_factory = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"local_transcriber.backends.faster_whisper.ensure_cublas_loadable", bootstrap
|
||||
)
|
||||
monkeypatch.setattr("faster_whisper.WhisperModel", model_factory)
|
||||
|
||||
model = FasterWhisperBackend().create_model("/model", device, "float32")
|
||||
|
||||
assert model is model_factory.return_value
|
||||
assert bootstrap.call_count == (1 if device == "cuda" else 0)
|
||||
model_factory.assert_called_once_with(
|
||||
"/model", device=device, compute_type="float32", cpu_threads=0
|
||||
)
|
||||
|
||||
|
||||
def test_transcribe_returns_canonical_words(tmp_path):
|
||||
audio = tmp_path / "audio.wav"
|
||||
raw_word = SimpleNamespace(start=0.2, end=0.7, word=" Привет")
|
||||
@@ -64,3 +83,18 @@ def test_transcribe_rejects_one_nonempty_segment_without_word_timestamps(tmp_pat
|
||||
|
||||
with pytest.raises(RuntimeError, match="пословные таймкоды"):
|
||||
FasterWhisperBackend().transcribe(model, tmp_path / "audio.wav", "ru")
|
||||
|
||||
|
||||
def test_runtime_info_reports_versions_and_cuda_devices(monkeypatch):
|
||||
"""Диагностика называет версии runtime и число CUDA-устройств, не обещая ускорения."""
|
||||
monkeypatch.setattr("ctranslate2.get_cuda_device_count", lambda: 0)
|
||||
backend = FasterWhisperBackend()
|
||||
backend.actual_compute_type = "float32"
|
||||
|
||||
info = backend.runtime_info()
|
||||
|
||||
assert info["ctranslate2"]
|
||||
assert info["faster_whisper"]
|
||||
assert info["cuda_devices"] == "0"
|
||||
assert info["compute_type"] == "float32"
|
||||
assert info["cpu_threads"] == "по умолчанию"
|
||||
@@ -463,3 +463,21 @@ def test_validate_model_dir_requires_alignment_heads_for_word_timestamps(tmp_pat
|
||||
|
||||
with pytest.raises(ValueError, match="alignment_heads"):
|
||||
_validate_model_dir(tmp_path)
|
||||
|
||||
|
||||
def test_runtime_info_reports_available_and_actual_devices(monkeypatch):
|
||||
"""Диагностика показывает устройства OpenVINO и то, что выбрано фактически."""
|
||||
core = MagicMock()
|
||||
core.available_devices = ["CPU", "GPU"]
|
||||
monkeypatch.setattr("openvino.Core", lambda: core)
|
||||
backend = OpenVINOBackend(ov_device="openvino-gpu", compute_type_explicit=True)
|
||||
backend.actual_ov_device = "GPU"
|
||||
backend.actual_compute_type = "fp16"
|
||||
|
||||
info = backend.runtime_info()
|
||||
|
||||
assert info["openvino"]
|
||||
assert info["available_devices"] == "CPU, GPU"
|
||||
assert info["device"] == "GPU"
|
||||
assert info["compute_type"] == "fp16"
|
||||
assert "--threads не применяется" in info["threads"]
|
||||
File diff suppressed because it is too large.
Load diff
+442
-839
File diff suppressed because it is too large.
Load diff
+34
-74
@@ -1,10 +1,8 @@
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from local_transcriber.config import (
|
||||
apply_device_defaults,
|
||||
find_config_file,
|
||||
load_config,
|
||||
resolve_defaults,
|
||||
@@ -41,6 +39,23 @@ def test_load_config_valid(tmp_path):
|
||||
assert result == {"model": "small", "language": "ru"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [True, False])
|
||||
def test_load_config_accepts_diarize_boolean(tmp_path, value):
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text(f"diarize = {str(value).lower()}\n")
|
||||
|
||||
assert load_config(config) == {"diarize": value}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("toml_value", ['"true"', "1"])
|
||||
def test_load_config_rejects_non_boolean_diarize(tmp_path, toml_value):
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text(f"diarize = {toml_value}\n")
|
||||
|
||||
with pytest.raises(ValueError, match="должно быть логическим"):
|
||||
load_config(config)
|
||||
|
||||
|
||||
def test_load_config_malformed(tmp_path):
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text("this is not valid toml [[[")
|
||||
@@ -91,64 +106,33 @@ def test_resolve_defaults_config_wins():
|
||||
assert result["model"] == "tiny"
|
||||
|
||||
|
||||
def test_resolve_defaults_hardcoded_fallback():
|
||||
def test_resolve_defaults_leaves_device_aware_keys_empty():
|
||||
result = resolve_defaults(
|
||||
{"model": None, "language": None, "device": None, "compute_type": None}, {}
|
||||
)
|
||||
assert result == {
|
||||
"model": "medium",
|
||||
"model": None,
|
||||
"language": "ru",
|
||||
"device": "auto",
|
||||
"compute_type": "float32",
|
||||
"compute_type": None,
|
||||
"diarize": False,
|
||||
}
|
||||
|
||||
|
||||
def test_apply_device_defaults_cuda():
|
||||
defaults = {"model": "medium", "language": "ru", "device": "auto", "compute_type": "float32"}
|
||||
cli = {"model": None, "language": None, "device": None, "compute_type": None}
|
||||
result = apply_device_defaults(defaults, "cuda", cli, {})
|
||||
assert result["model"] == "medium"
|
||||
assert result["compute_type"] == "float16"
|
||||
@pytest.mark.parametrize(
|
||||
("cli_value", "config_value", "expected"),
|
||||
[
|
||||
(None, True, True),
|
||||
(None, False, False),
|
||||
(True, False, True),
|
||||
(False, True, False),
|
||||
],
|
||||
)
|
||||
def test_resolve_defaults_diarize_priority(cli_value, config_value, expected):
|
||||
cli = {"diarize": cli_value}
|
||||
config = {"diarize": config_value}
|
||||
|
||||
|
||||
def test_apply_device_defaults_cpu():
|
||||
defaults = {"model": "medium", "language": "ru", "device": "auto", "compute_type": "float32"}
|
||||
cli = {"model": None, "language": None, "device": None, "compute_type": None}
|
||||
result = apply_device_defaults(defaults, "cpu", cli, {})
|
||||
assert result["model"] == "medium"
|
||||
assert result["compute_type"] == "float32"
|
||||
|
||||
|
||||
def test_apply_device_defaults_onnx_uses_readable_model():
|
||||
defaults = {
|
||||
"model": "medium",
|
||||
"language": "ru",
|
||||
"device": "auto",
|
||||
"compute_type": "float32",
|
||||
}
|
||||
cli = {"model": None, "language": None, "device": None, "compute_type": None}
|
||||
|
||||
result = apply_device_defaults(defaults, "onnx", cli, {})
|
||||
|
||||
assert result["model"] == "gigaam-v3-e2e-rnnt"
|
||||
assert result["compute_type"] == "int8"
|
||||
|
||||
|
||||
def test_apply_device_defaults_cli_overrides():
|
||||
defaults = {"model": "large-v3", "language": "ru", "device": "auto", "compute_type": "int8"}
|
||||
cli = {"model": "large-v3", "language": None, "device": None, "compute_type": "int8"}
|
||||
result = apply_device_defaults(defaults, "cuda", cli, {})
|
||||
assert result["model"] == "large-v3"
|
||||
assert result["compute_type"] == "int8"
|
||||
|
||||
|
||||
def test_apply_device_defaults_config_overrides():
|
||||
defaults = {"model": "small", "language": "ru", "device": "auto", "compute_type": "int8"}
|
||||
cli = {"model": None, "language": None, "device": None, "compute_type": None}
|
||||
config = {"model": "small", "compute_type": "int8"}
|
||||
result = apply_device_defaults(defaults, "cuda", cli, config)
|
||||
assert result["model"] == "small"
|
||||
assert result["compute_type"] == "int8"
|
||||
assert resolve_defaults(cli, config)["diarize"] is expected
|
||||
|
||||
|
||||
def test_load_config_openvino_device(tmp_path):
|
||||
@@ -170,27 +154,3 @@ def test_load_config_openvino_cpu_device(tmp_path):
|
||||
config.write_text('device = "openvino-cpu"\n')
|
||||
result = load_config(config)
|
||||
assert result == {"device": "openvino-cpu"}
|
||||
|
||||
|
||||
def test_apply_device_defaults_openvino():
|
||||
defaults = {"model": "medium", "language": "ru", "device": "auto", "compute_type": "float32"}
|
||||
cli = {"model": None, "language": None, "device": None, "compute_type": None}
|
||||
result = apply_device_defaults(defaults, "openvino", cli, {})
|
||||
assert result["model"] == "medium"
|
||||
assert result["compute_type"] == "int8"
|
||||
|
||||
|
||||
def test_apply_device_defaults_openvino_gpu():
|
||||
defaults = {"model": "medium", "language": "ru", "device": "auto", "compute_type": "float32"}
|
||||
cli = {"model": None, "language": None, "device": None, "compute_type": None}
|
||||
result = apply_device_defaults(defaults, "openvino-gpu", cli, {})
|
||||
assert result["model"] == "medium"
|
||||
assert result["compute_type"] == "int8"
|
||||
|
||||
|
||||
def test_apply_device_defaults_openvino_cpu():
|
||||
defaults = {"model": "medium", "language": "ru", "device": "auto", "compute_type": "float32"}
|
||||
cli = {"model": None, "language": None, "device": None, "compute_type": None}
|
||||
result = apply_device_defaults(defaults, "openvino-cpu", cli, {})
|
||||
assert result["model"] == "medium"
|
||||
assert result["compute_type"] == "int8"
|
||||
@@ -48,12 +48,73 @@ def test_ensure_cublas_loads_library(monkeypatch, tmp_path):
|
||||
assert calls[0][1] == ctypes.RTLD_GLOBAL
|
||||
|
||||
|
||||
def test_ensure_cublas_skips_non_linux(monkeypatch):
|
||||
"""На не-Linux платформах -- no-op."""
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
def test_ensure_cublas_skips_macos(monkeypatch):
|
||||
"""На macOS bootstrap не загружает CUDA."""
|
||||
monkeypatch.setattr(sys, "platform", "darwin")
|
||||
ensure_cublas_loadable() # не должно бросать исключений
|
||||
|
||||
|
||||
def test_windows_optional_package_absent(monkeypatch):
|
||||
"""Windows работает без extra и не меняет поиск DLL."""
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
monkeypatch.setitem(sys.modules, "nvidia.cublas", None)
|
||||
ensure_cublas_loadable()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("broken", [False, True])
|
||||
def test_windows_preloads_dlls_and_keeps_handles(monkeypatch, tmp_path, broken):
|
||||
"""DLL загружаются по порядку один раз, ошибки не скрываются."""
|
||||
from local_transcriber import _cuda_bootstrap as bootstrap
|
||||
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir()
|
||||
nvidia = types.ModuleType("nvidia")
|
||||
nvidia.__path__ = []
|
||||
cublas = types.ModuleType("nvidia.cublas")
|
||||
cublas.__path__ = [str(tmp_path)]
|
||||
nvidia.cublas = cublas
|
||||
monkeypatch.setitem(sys.modules, "nvidia", nvidia)
|
||||
monkeypatch.setitem(sys.modules, "nvidia.cublas", cublas)
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
monkeypatch.setattr(bootstrap, "_dll_directories", {})
|
||||
monkeypatch.setattr(bootstrap, "_dll_libraries", {})
|
||||
calls = []
|
||||
directory_handle = object()
|
||||
library_handle = object()
|
||||
failure = OSError("cublas64_12.dll: dependency not found")
|
||||
|
||||
def add_directory(path):
|
||||
calls.append(("directory", path))
|
||||
return directory_handle
|
||||
|
||||
def load_library(path):
|
||||
calls.append(("load", path))
|
||||
if broken:
|
||||
raise failure
|
||||
return library_handle
|
||||
|
||||
monkeypatch.setattr(os, "add_dll_directory", add_directory, raising=False)
|
||||
monkeypatch.setattr(ctypes, "WinDLL", load_library, raising=False)
|
||||
original_path = os.environ.get("PATH")
|
||||
|
||||
if broken:
|
||||
with pytest.raises(OSError) as caught:
|
||||
ensure_cublas_loadable()
|
||||
assert caught.value is failure
|
||||
assert bootstrap._dll_libraries == {}
|
||||
else:
|
||||
ensure_cublas_loadable()
|
||||
ensure_cublas_loadable()
|
||||
assert calls == [
|
||||
("directory", str(bin_dir)),
|
||||
("load", str(bin_dir / "cublasLt64_12.dll")),
|
||||
("load", str(bin_dir / "cublas64_12.dll")),
|
||||
]
|
||||
assert list(bootstrap._dll_libraries.values()) == [library_handle] * 2
|
||||
assert bootstrap._dll_directories[str(bin_dir)] is directory_handle
|
||||
assert os.environ.get("PATH") == original_path
|
||||
|
||||
|
||||
def _nvidia_cublas_installed() -> bool:
|
||||
"""Проверяет, что pip-пакет nvidia-cublas-cu12 установлен."""
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,930 @@
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPT = (
|
||||
Path(__file__).parents[1] / "scripts" / "benchmarks" / "diarization_calibration.py"
|
||||
)
|
||||
SPEC = importlib.util.spec_from_file_location("diarization_calibration", SCRIPT)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
calibration = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = calibration
|
||||
SPEC.loader.exec_module(calibration)
|
||||
|
||||
|
||||
def _write(path, content):
|
||||
path.write_bytes(content)
|
||||
return {
|
||||
"path": str(path),
|
||||
"sha256": hashlib.sha256(content).hexdigest(),
|
||||
"size_bytes": len(content),
|
||||
}
|
||||
|
||||
|
||||
def _manifest(tmp_path):
|
||||
fp32 = _write(tmp_path / "fp32.onnx", b"fp32")
|
||||
int8 = _write(tmp_path / "int8.onnx", b"int8")
|
||||
wespeaker = _write(tmp_path / "wespeaker.onnx", b"wespeaker")
|
||||
titanet = _write(tmp_path / "titanet.onnx", b"titanet")
|
||||
segmentations = [
|
||||
{
|
||||
"id": "pyannote-fp32",
|
||||
"precision": "fp32",
|
||||
"source_url": "https://example/fp32",
|
||||
"license": "MIT",
|
||||
**fp32,
|
||||
},
|
||||
{
|
||||
"id": "pyannote-int8",
|
||||
"precision": "int8",
|
||||
"source_url": "https://example/int8",
|
||||
"license": "MIT",
|
||||
**int8,
|
||||
},
|
||||
]
|
||||
embeddings = [
|
||||
{
|
||||
"id": "wespeaker",
|
||||
"source_url": "https://example/wespeaker",
|
||||
"license": "CC-BY-4.0",
|
||||
**wespeaker,
|
||||
},
|
||||
{
|
||||
"id": "titanet-small",
|
||||
"source_url": "https://example/titanet",
|
||||
"license": "Apache-2.0",
|
||||
**titanet,
|
||||
},
|
||||
]
|
||||
recordings = []
|
||||
for index, (recording_id, speakers) in enumerate(
|
||||
(("data-test", 3), ("t2-bdma", 2), ("yantar", 2))
|
||||
):
|
||||
media = _write(tmp_path / f"{recording_id}.mp4", recording_id.encode())
|
||||
words_content = json.dumps(
|
||||
[{"start": 0.0, "end": 0.5, "text": " Слово"}]
|
||||
).encode()
|
||||
words = _write(tmp_path / f"{recording_id}.words.json", words_content)
|
||||
reference = None
|
||||
if index:
|
||||
reference_content = b"**[00:00] Speaker 1:**\n"
|
||||
ref = _write(tmp_path / f"{recording_id}.md", reference_content)
|
||||
reference = {
|
||||
"path": ref["path"],
|
||||
"sha256": ref["sha256"],
|
||||
"format": "hypescribe_markdown",
|
||||
}
|
||||
recordings.append(
|
||||
{
|
||||
"id": recording_id,
|
||||
"path": media["path"],
|
||||
"sha256": media["sha256"],
|
||||
"start": 0.0,
|
||||
"duration": 10.0,
|
||||
"expected_speakers": speakers,
|
||||
"reference": reference,
|
||||
"asr_words": {
|
||||
"path": words["path"],
|
||||
"sha256": words["sha256"],
|
||||
"format": "local_transcriber_words_v1",
|
||||
},
|
||||
}
|
||||
)
|
||||
return {
|
||||
"schema_version": 2,
|
||||
"segmentation_models": segmentations,
|
||||
"embedding_models": embeddings,
|
||||
"combinations": [
|
||||
{
|
||||
"id": "fp32-wespeaker",
|
||||
"segmentation_id": "pyannote-fp32",
|
||||
"embedding_id": "wespeaker",
|
||||
"role": "baseline",
|
||||
"thresholds": [0.8, 0.9],
|
||||
},
|
||||
{
|
||||
"id": "fp32-titanet",
|
||||
"segmentation_id": "pyannote-fp32",
|
||||
"embedding_id": "titanet-small",
|
||||
"role": "candidate",
|
||||
"thresholds": [0.8],
|
||||
},
|
||||
{
|
||||
"id": "int8-wespeaker",
|
||||
"segmentation_id": "pyannote-int8",
|
||||
"embedding_id": "wespeaker",
|
||||
"role": "candidate",
|
||||
"thresholds": [0.8],
|
||||
},
|
||||
{
|
||||
"id": "int8-titanet",
|
||||
"segmentation_id": "pyannote-int8",
|
||||
"embedding_id": "titanet-small",
|
||||
"role": "candidate",
|
||||
"thresholds": [0.8],
|
||||
},
|
||||
],
|
||||
"recordings": recordings,
|
||||
"threads": 4,
|
||||
"rss_sample_interval_ms": 5,
|
||||
}
|
||||
|
||||
|
||||
def _environment():
|
||||
return {
|
||||
"sherpa_onnx_version": "1.13.6",
|
||||
"onnxruntime_version": "1",
|
||||
"numpy_version": "2",
|
||||
"psutil_version": "7",
|
||||
}
|
||||
|
||||
|
||||
def test_validate_only_requires_only_manifest():
|
||||
args = calibration.parse_args(["--manifest", "manifest.json", "--validate-only"])
|
||||
|
||||
assert args.manifest == Path("manifest.json")
|
||||
assert args.output is None
|
||||
assert args.work_dir is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"extra",
|
||||
[
|
||||
["--output", "unused"],
|
||||
["--work-dir", "unused"],
|
||||
["--prepare-asr", "data-test"],
|
||||
["--asr-model", "model"],
|
||||
["--asr-device", "cpu"],
|
||||
["--asr-compute-type", "float32"],
|
||||
["--asr-language", "ru"],
|
||||
],
|
||||
)
|
||||
def test_validate_only_rejects_unused_run_options(extra):
|
||||
with pytest.raises(SystemExit):
|
||||
calibration.parse_args(
|
||||
["--manifest", "manifest.json", "--validate-only", *extra]
|
||||
)
|
||||
|
||||
|
||||
def test_relative_manifest_paths_resolve_from_manifest_directory(tmp_path):
|
||||
manifest = _manifest(tmp_path)
|
||||
for model in manifest["segmentation_models"] + manifest["embedding_models"]:
|
||||
model["path"] = Path(model["path"]).name
|
||||
for recording in manifest["recordings"]:
|
||||
recording["path"] = Path(recording["path"]).name
|
||||
recording["asr_words"]["path"] = Path(recording["asr_words"]["path"]).name
|
||||
if recording["reference"] is not None:
|
||||
recording["reference"]["path"] = Path(recording["reference"]["path"]).name
|
||||
|
||||
resolved = calibration.resolve_manifest_paths(manifest, tmp_path)
|
||||
|
||||
assert calibration.validate_manifest(resolved)["schema_version"] == 2
|
||||
assert Path(resolved["recordings"][0]["path"]).is_absolute()
|
||||
|
||||
|
||||
def test_validate_only_prints_safe_plan(tmp_path, monkeypatch, capsys):
|
||||
manifest_path = tmp_path / "manifest.json"
|
||||
manifest = _manifest(tmp_path)
|
||||
for model in manifest["segmentation_models"] + manifest["embedding_models"]:
|
||||
model["path"] = Path(model["path"]).name
|
||||
for recording in manifest["recordings"]:
|
||||
recording["path"] = Path(recording["path"]).name
|
||||
recording["asr_words"]["path"] = Path(recording["asr_words"]["path"]).name
|
||||
if recording["reference"] is not None:
|
||||
recording["reference"]["path"] = Path(recording["reference"]["path"]).name
|
||||
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||
monkeypatch.setattr(calibration, "validate_original_corpus", lambda manifest: None)
|
||||
monkeypatch.setattr(calibration, "environment_snapshot", _environment)
|
||||
|
||||
calibration.main(["--manifest", str(manifest_path), "--validate-only"])
|
||||
|
||||
summary = json.loads(capsys.readouterr().out)
|
||||
assert summary["status"] == "valid"
|
||||
assert summary["automatic_cells"] == 15
|
||||
assert summary["known_cells_if_all_selected"] == 12
|
||||
assert str(tmp_path) not in json.dumps(summary)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("key", "value"),
|
||||
[("sherpa_onnx_version", "1.14.0"), ("psutil_version", "unavailable")],
|
||||
)
|
||||
def test_validate_environment_rejects_incompatible_dependencies(key, value):
|
||||
environment = _environment()
|
||||
environment[key] = value
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
calibration.validate_environment(environment)
|
||||
|
||||
|
||||
def _complete_cell(spec, clusters, purity=None, residual=0.0):
|
||||
return {
|
||||
**spec,
|
||||
"status": "complete",
|
||||
"diagnostics": {
|
||||
"clusters": clusters,
|
||||
"mapped_speaker_purity": purity,
|
||||
"residual_share_after_expected": residual,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_manifest_validates_required_matrix_and_files(tmp_path):
|
||||
assert calibration.validate_manifest(_manifest(tmp_path))["schema_version"] == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutation", ["missing_combination", "duplicate_id", "unsorted_threshold"]
|
||||
)
|
||||
def test_manifest_rejects_invalid_matrix_and_ids(tmp_path, mutation):
|
||||
manifest = _manifest(tmp_path)
|
||||
if mutation == "missing_combination":
|
||||
manifest["combinations"].pop()
|
||||
elif mutation == "duplicate_id":
|
||||
manifest["recordings"][1]["id"] = manifest["recordings"][0]["id"]
|
||||
else:
|
||||
manifest["combinations"][0]["thresholds"] = [0.9, 0.8]
|
||||
with pytest.raises(ValueError):
|
||||
calibration.validate_manifest(manifest, verify_files=False)
|
||||
|
||||
|
||||
def test_manifest_rejects_hash_mismatch_before_work(tmp_path):
|
||||
manifest = _manifest(tmp_path)
|
||||
manifest["recordings"][0]["sha256"] = "0" * 64
|
||||
with pytest.raises(ValueError, match="SHA-256"):
|
||||
calibration.validate_manifest(manifest)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model_kind", ["segmentation", "embedding"])
|
||||
def test_manifest_rejects_multiple_exact_model_ids_per_matrix_axis(
|
||||
tmp_path, model_kind
|
||||
):
|
||||
manifest = _manifest(tmp_path)
|
||||
if model_kind == "segmentation":
|
||||
duplicate = dict(manifest["segmentation_models"][0])
|
||||
duplicate["id"] = "pyannote-fp32-other"
|
||||
manifest["segmentation_models"].append(duplicate)
|
||||
manifest["combinations"][1]["segmentation_id"] = duplicate["id"]
|
||||
else:
|
||||
duplicate = dict(manifest["embedding_models"][0])
|
||||
duplicate["id"] = "wespeaker-other"
|
||||
manifest["embedding_models"].append(duplicate)
|
||||
manifest["combinations"][2]["embedding_id"] = duplicate["id"]
|
||||
|
||||
with pytest.raises(ValueError, match="ровно один"):
|
||||
calibration.validate_manifest(manifest, verify_files=False)
|
||||
|
||||
|
||||
def test_complete_run_rejects_substitute_corpus(tmp_path):
|
||||
manifest = _manifest(tmp_path)
|
||||
with pytest.raises(ValueError, match="Корпус"):
|
||||
calibration.validate_original_corpus(manifest)
|
||||
for recording in manifest["recordings"]:
|
||||
recording.update(calibration.ORIGINAL_CORPUS[recording["id"]])
|
||||
calibration.validate_original_corpus(manifest)
|
||||
|
||||
|
||||
def test_experiment_identity_ignores_paths_but_rejects_semantic_resume(tmp_path):
|
||||
manifest = _manifest(tmp_path)
|
||||
first = calibration.make_experiment_id(manifest, _environment())
|
||||
moved = json.loads(json.dumps(manifest))
|
||||
moved["recordings"][0]["path"] = "/different/private/path.mp4"
|
||||
assert calibration.make_experiment_id(moved, _environment()) == first
|
||||
output = tmp_path / "result.json"
|
||||
calibration.save_output(output, {"schema_version": 2, "experiment_id": "f" * 64})
|
||||
with pytest.raises(ValueError, match="другому семантическому"):
|
||||
calibration._load_or_create_output(output, manifest, _environment(), first)
|
||||
|
||||
|
||||
def test_resume_rejects_different_measurement_host_without_path_identity(tmp_path):
|
||||
manifest = _manifest(tmp_path)
|
||||
first_environment = {
|
||||
**_environment(),
|
||||
"started_at_utc": "2026-08-29T00:00:00+00:00",
|
||||
"platform": "host-a",
|
||||
"cpu": "cpu-a",
|
||||
"logical_cpus": 16,
|
||||
"python_version": "3.13",
|
||||
}
|
||||
experiment_id = calibration.make_experiment_id(manifest, first_environment)
|
||||
output_path = tmp_path / "result.json"
|
||||
calibration.save_output(
|
||||
output_path,
|
||||
calibration._new_output(manifest, first_environment, experiment_id),
|
||||
)
|
||||
moved = json.loads(json.dumps(manifest))
|
||||
moved["recordings"][0]["path"] = "/moved/private/recording.mp4"
|
||||
assert calibration.make_experiment_id(moved, first_environment) == experiment_id
|
||||
second_environment = {
|
||||
**first_environment,
|
||||
"started_at_utc": "2026-08-30T00:00:00+00:00",
|
||||
"platform": "host-b",
|
||||
"cpu": "cpu-b",
|
||||
"logical_cpus": 2,
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="другой platform/cpu/logical_cpus"):
|
||||
calibration._load_or_create_output(
|
||||
output_path, moved, second_environment, experiment_id
|
||||
)
|
||||
|
||||
|
||||
def test_cell_expansion_is_rectangular_deterministic_and_path_independent(tmp_path):
|
||||
manifest = _manifest(tmp_path)
|
||||
experiment_id = calibration.make_experiment_id(manifest, _environment())
|
||||
cells = calibration.expand_automatic_cells(manifest, experiment_id)
|
||||
assert len(cells) == 15
|
||||
assert [
|
||||
(cell["combination_id"], cell["recording_id"], cell["threshold"])
|
||||
for cell in cells[:4]
|
||||
] == [
|
||||
("fp32-wespeaker", "data-test", 0.8),
|
||||
("fp32-wespeaker", "t2-bdma", 0.8),
|
||||
("fp32-wespeaker", "yantar", 0.8),
|
||||
("fp32-wespeaker", "data-test", 0.9),
|
||||
]
|
||||
assert all(cell["num_clusters"] == -1 for cell in cells)
|
||||
moved = json.loads(json.dumps(manifest))
|
||||
for model in moved["segmentation_models"] + moved["embedding_models"]:
|
||||
model["path"] = f"/moved/models/{model['id']}.onnx"
|
||||
for recording in moved["recordings"]:
|
||||
recording["path"] = f"/moved/media/{recording['id']}.mp4"
|
||||
recording["asr_words"]["path"] = f"/moved/words/{recording['id']}.json"
|
||||
if recording["reference"]:
|
||||
recording["reference"]["path"] = f"/moved/refs/{recording['id']}.md"
|
||||
moved_experiment_id = calibration.make_experiment_id(moved, _environment())
|
||||
assert moved_experiment_id == experiment_id
|
||||
assert calibration.expand_automatic_cells(moved, moved_experiment_id) == cells
|
||||
|
||||
|
||||
def test_threshold_selection_requires_complete_rectangle_and_uses_shared_gate(tmp_path):
|
||||
manifest = _manifest(tmp_path)
|
||||
combination = manifest["combinations"][0]
|
||||
specs = calibration.expand_automatic_cells(manifest, "e" * 64)[:6]
|
||||
assert calibration.select_threshold(combination, manifest["recordings"], []) is None
|
||||
counts = [3, 2, 3, 3, 2, 2]
|
||||
purities = [None, 0.7, 0.9, None, 0.8, 0.8]
|
||||
cells = [
|
||||
_complete_cell(spec, count, purity)
|
||||
for spec, count, purity in zip(specs, counts, purities, strict=True)
|
||||
]
|
||||
selection = calibration.select_threshold(combination, manifest["recordings"], cells)
|
||||
assert selection["selected_threshold"] == 0.9
|
||||
assert selection["meets_count_gate"] is True
|
||||
known = calibration.derive_known_cells(manifest, "e" * 64, selection)
|
||||
assert [cell["num_clusters"] for cell in known] == [3, 2, 2]
|
||||
assert {cell["threshold"] for cell in known} == {0.9}
|
||||
|
||||
|
||||
def test_threshold_selection_records_failed_gate(tmp_path):
|
||||
manifest = _manifest(tmp_path)
|
||||
combination = manifest["combinations"][1]
|
||||
specs = [
|
||||
cell
|
||||
for cell in calibration.expand_automatic_cells(manifest, "e" * 64)
|
||||
if cell["combination_id"] == combination["id"]
|
||||
]
|
||||
cells = [
|
||||
_complete_cell(spec, count, purity)
|
||||
for spec, count, purity in zip(specs, [4, 3, 3], [None, 0.8, 0.8], strict=True)
|
||||
]
|
||||
assert (
|
||||
calibration.select_threshold(combination, manifest["recordings"], cells)[
|
||||
"meets_count_gate"
|
||||
]
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_stage_timing_parser_accepts_native_lines():
|
||||
log = (
|
||||
"[I] OfflineSpeakerDiarization: segmentation 1.000 s\n"
|
||||
"[I] OfflineSpeakerDiarization: embedding 2.000 s\n"
|
||||
"[I] OfflineSpeakerDiarization: clustering 0.100 s\n"
|
||||
"[I] OfflineSpeakerDiarization: total 3.100 s, audio 10.000 s, RTF 0.310"
|
||||
)
|
||||
stages = calibration.parse_stage_timings(log, "1.13.6")
|
||||
assert stages["engine_total_seconds"] == 3.1
|
||||
assert stages["engine_audio_seconds"] == 10.0
|
||||
assert stages["engine_rtf"] == 0.31
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"log",
|
||||
[
|
||||
"OfflineSpeakerDiarization: segmentation 1.0 s",
|
||||
"OfflineSpeakerDiarization: segmentation 1.0 s\nOfflineSpeakerDiarization: segmentation 1.0 s\nOfflineSpeakerDiarization: embedding 1.0 s\nOfflineSpeakerDiarization: clustering 1.0 s\nOfflineSpeakerDiarization: total 3.0 s, audio 4 s, RTF .75",
|
||||
"OfflineSpeakerDiarization: segmentation -1.0 s\nOfflineSpeakerDiarization: embedding 1.0 s\nOfflineSpeakerDiarization: clustering 1.0 s\nOfflineSpeakerDiarization: total 1.0 s, audio 4 s, RTF .25",
|
||||
],
|
||||
)
|
||||
def test_stage_timing_parser_rejects_missing_duplicate_and_negative(log):
|
||||
with pytest.raises(ValueError):
|
||||
calibration.parse_stage_timings(log, "1.13.6")
|
||||
|
||||
|
||||
def test_stage_timing_parser_rejects_extra_malformed_record():
|
||||
log = (
|
||||
"OfflineSpeakerDiarization: segmentation ??? s\n"
|
||||
"OfflineSpeakerDiarization: segmentation 1.000 s\n"
|
||||
"OfflineSpeakerDiarization: embedding 2.000 s\n"
|
||||
"OfflineSpeakerDiarization: clustering 0.100 s\n"
|
||||
"OfflineSpeakerDiarization: total 3.100 s, audio 10.000 s, RTF 0.310"
|
||||
)
|
||||
with pytest.raises(ValueError, match="повреждённую"):
|
||||
calibration.parse_stage_timings(log, "1.13.6")
|
||||
|
||||
|
||||
def test_measurement_uses_injected_cpu_clock_and_rss_once():
|
||||
process = SimpleNamespace()
|
||||
calls = []
|
||||
cpu_times = iter(
|
||||
[SimpleNamespace(user=1.0, system=2.0), SimpleNamespace(user=2.0, system=2.5)]
|
||||
)
|
||||
|
||||
def read_cpu_times():
|
||||
calls.append("cpu")
|
||||
return next(cpu_times)
|
||||
|
||||
process.cpu_times = read_cpu_times
|
||||
|
||||
class Sampler:
|
||||
def __init__(self, actual_process, interval):
|
||||
assert actual_process is process
|
||||
assert interval == 0.01
|
||||
|
||||
def start(self):
|
||||
calls.append("start")
|
||||
|
||||
def stop(self):
|
||||
calls.append("stop")
|
||||
return 1234
|
||||
|
||||
clock = iter([10.0, 12.0]).__next__
|
||||
result, metrics = calibration.measure_call(
|
||||
lambda: calls.append("process") or "ok",
|
||||
process,
|
||||
clock=clock,
|
||||
sampler_factory=Sampler,
|
||||
interval_seconds=0.01,
|
||||
logical_cpus=4,
|
||||
)
|
||||
assert result == "ok"
|
||||
assert calls == ["start", "cpu", "process", "cpu", "stop"]
|
||||
assert metrics["cpu_total_seconds"] == 1.5
|
||||
assert metrics["average_cpu_percent_machine"] == 18.75
|
||||
assert metrics["peak_rss_bytes"] == 1234
|
||||
|
||||
|
||||
def test_measurement_preserves_operation_error_when_sampler_stop_also_fails():
|
||||
process = SimpleNamespace(
|
||||
cpu_times=iter(
|
||||
[
|
||||
SimpleNamespace(user=1.0, system=2.0),
|
||||
SimpleNamespace(user=1.1, system=2.1),
|
||||
]
|
||||
).__next__
|
||||
)
|
||||
|
||||
class Sampler:
|
||||
def __init__(self, process, interval):
|
||||
pass
|
||||
|
||||
def start(self):
|
||||
pass
|
||||
|
||||
def stop(self):
|
||||
raise RuntimeError("sampler failed")
|
||||
|
||||
def operation():
|
||||
raise ValueError("operation failed")
|
||||
|
||||
with pytest.raises(ValueError, match="operation failed") as error:
|
||||
calibration.measure_call(
|
||||
operation,
|
||||
process,
|
||||
clock=iter([1.0, 2.0]).__next__,
|
||||
sampler_factory=Sampler,
|
||||
interval_seconds=0.01,
|
||||
logical_cpus=4,
|
||||
)
|
||||
assert any("sampler failed" in note for note in error.value.__notes__)
|
||||
|
||||
|
||||
def test_decode_cache_identity_covers_hash_bounds_and_decoder():
|
||||
recording = {"sha256": "a" * 64, "start": 1.0, "duration": 2.0}
|
||||
original = calibration.decode_cache_key(recording)
|
||||
assert calibration.decode_cache_key({**recording, "start": 2.0}) != original
|
||||
assert calibration.decode_cache_key({**recording, "sha256": "b" * 64}) != original
|
||||
assert calibration.decode_cache_key(recording, "another-decoder") != original
|
||||
|
||||
|
||||
def test_decode_clip_declares_wav_format_for_temporary_output(tmp_path, monkeypatch):
|
||||
recording = {
|
||||
"id": "synthetic",
|
||||
"path": str(tmp_path / "source.mp4"),
|
||||
"sha256": "a" * 64,
|
||||
"start": 1.0,
|
||||
"duration": 2.0,
|
||||
}
|
||||
commands = []
|
||||
|
||||
def run(command, *, check):
|
||||
assert check is True
|
||||
commands.append(command)
|
||||
Path(command[-1]).touch()
|
||||
|
||||
monkeypatch.setattr(calibration.subprocess, "run", run)
|
||||
monkeypatch.setattr(calibration, "read_wav", lambda path: None)
|
||||
|
||||
output = calibration.decode_clip(recording, tmp_path / "work")
|
||||
|
||||
assert output.is_file()
|
||||
assert not output.with_suffix(".wav.tmp").exists()
|
||||
assert commands[0][-3:-1] == ["-f", "wav"]
|
||||
|
||||
|
||||
def test_hypescribe_parser_mapping_and_empty_diagnostics(tmp_path):
|
||||
reference = tmp_path / "reference.md"
|
||||
reference.write_text(
|
||||
"**[00:10] Speaker 1:**\ntext\n**[00:15] Speaker 2:**\n", encoding="utf-8"
|
||||
)
|
||||
recording = {
|
||||
"id": "clip",
|
||||
"path": str(tmp_path / "clip.mp4"),
|
||||
"sha256": "a" * 64,
|
||||
"start": 12.0,
|
||||
"duration": 6.0,
|
||||
"expected_speakers": 2,
|
||||
"reference": {"path": str(reference)},
|
||||
}
|
||||
turns = calibration.read_reference_turns(recording)
|
||||
assert turns == [
|
||||
{"speaker": "1", "start": 0.0, "end": 3.0},
|
||||
{"speaker": "2", "start": 3.0, "end": 6.0},
|
||||
]
|
||||
mapping = calibration.best_mapping(
|
||||
[
|
||||
{"speaker": 7, "start": 0.0, "end": 3.0},
|
||||
{"speaker": 8, "start": 3.0, "end": 6.0},
|
||||
],
|
||||
turns,
|
||||
)
|
||||
assert mapping["mapped_speaker_purity"] == 1.0
|
||||
assert calibration.best_mapping([], turns) is None
|
||||
summary = calibration.summarize_segments([], recording)
|
||||
assert summary["clusters"] == 0 and summary["residual_share_after_expected"] is None
|
||||
|
||||
|
||||
def test_asr_sidecar_validation_text_invariance_and_unknown_words(tmp_path):
|
||||
sidecar = tmp_path / "words.json"
|
||||
sidecar.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{"start": 0.0, "end": 0.5, "text": "Привет"},
|
||||
{"start": 0.5, "end": 0.6, "text": "!"},
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
words = calibration.load_asr_words(sidecar, 2.0)
|
||||
recording = {"duration": 2.0, "expected_speakers": 1}
|
||||
diagnostics, invariant = calibration.evaluate_segments(
|
||||
[{"speaker": 0, "start": 0.0, "end": 0.5}], recording, words, []
|
||||
)
|
||||
assert diagnostics["unassigned_word_count"] == 1
|
||||
assert diagnostics["mapped_speaker_purity"] is None
|
||||
assert invariant["equal"] is True and invariant["first_mismatch_index"] is None
|
||||
sidecar.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{"start": 1.0, "end": 1.1, "text": "a"},
|
||||
{"start": 0.5, "end": 0.6, "text": "b"},
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(ValueError, match="границы"):
|
||||
calibration.load_asr_words(sidecar, 2.0)
|
||||
|
||||
|
||||
def test_asr_sidecar_rejects_empty_baseline(tmp_path):
|
||||
sidecar = tmp_path / "words.json"
|
||||
sidecar.write_text("[]", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="хотя бы одно слово"):
|
||||
calibration.load_asr_words(sidecar, 300.0)
|
||||
|
||||
|
||||
def test_text_invariance_records_first_mismatch_without_text():
|
||||
transcript = SimpleNamespace(turns=[SimpleNamespace(text="другой")])
|
||||
words = [SimpleNamespace(text="текст")]
|
||||
evidence = calibration.text_invariance(words, transcript)
|
||||
assert evidence["equal"] is False
|
||||
assert evidence["first_mismatch_index"] == 0
|
||||
assert "текст" not in json.dumps(evidence, ensure_ascii=False)
|
||||
|
||||
|
||||
def test_text_invariance_ignores_unicode_whitespace_at_turn_boundaries():
|
||||
words = [
|
||||
SimpleNamespace(text="Вопрос?"),
|
||||
SimpleNamespace(text=" — "),
|
||||
SimpleNamespace(text="ответ"),
|
||||
]
|
||||
transcript = SimpleNamespace(
|
||||
turns=[SimpleNamespace(text="Вопрос?"), SimpleNamespace(text="— ответ")]
|
||||
)
|
||||
|
||||
evidence = calibration.text_invariance(words, transcript)
|
||||
|
||||
assert calibration.canonical_non_whitespace_sequence(words) == "Вопрос?—ответ"
|
||||
assert evidence["equal"] is True
|
||||
assert evidence["first_mismatch_index"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("output", ["Вопрос?ответ", "Вопрос!—ответ"])
|
||||
def test_text_invariance_rejects_non_whitespace_deletion_or_substitution(output):
|
||||
words = [SimpleNamespace(text="Вопрос?"), SimpleNamespace(text="— ответ")]
|
||||
transcript = SimpleNamespace(turns=[SimpleNamespace(text=output)])
|
||||
|
||||
evidence = calibration.text_invariance(words, transcript)
|
||||
|
||||
assert evidence["equal"] is False
|
||||
assert evidence["input_sha256"] != evidence["output_sha256"]
|
||||
assert evidence["first_mismatch_index"] is not None
|
||||
|
||||
|
||||
def test_atomic_persistence_leaves_no_temporary_file(tmp_path):
|
||||
output = tmp_path / "nested" / "result.json"
|
||||
calibration.save_output(output, {"private": "value"})
|
||||
assert json.loads(output.read_text()) == {"private": "value"}
|
||||
assert list(output.parent.glob("*.tmp")) == []
|
||||
|
||||
|
||||
def test_atomic_persistence_preserves_target_and_cleans_temp_on_dump_failure(tmp_path):
|
||||
output = tmp_path / "result.json"
|
||||
calibration.save_output(output, {"stable": True})
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
calibration.save_output(output, {"unserializable": object()})
|
||||
|
||||
assert json.loads(output.read_text(encoding="utf-8")) == {"stable": True}
|
||||
assert list(output.parent.glob("*.tmp")) == []
|
||||
|
||||
|
||||
def test_worker_failure_is_persisted_and_retried(tmp_path, monkeypatch):
|
||||
output = {"cells": []}
|
||||
output_path = tmp_path / "result.json"
|
||||
spec = {
|
||||
"cell_id": "cell",
|
||||
"combination_id": "c",
|
||||
"recording_id": "r",
|
||||
"mode": "automatic",
|
||||
"threshold": 0.8,
|
||||
"num_clusters": -1,
|
||||
}
|
||||
attempts = []
|
||||
|
||||
def execute(*args, **kwargs):
|
||||
attempts.append(1)
|
||||
if len(attempts) == 1:
|
||||
raise RuntimeError("boom")
|
||||
return {**spec, "status": "complete"}
|
||||
|
||||
monkeypatch.setattr(calibration, "_execute_cell", execute)
|
||||
calibration._run_specs([spec], {}, output, {}, output_path, tmp_path)
|
||||
assert output["cells"][0]["status"] == "failed"
|
||||
assert output["cells"][0]["metrics"] is None
|
||||
calibration._run_specs([spec], {}, output, {}, output_path, tmp_path)
|
||||
assert output["cells"][0]["status"] == "complete"
|
||||
assert len(attempts) == 2
|
||||
|
||||
|
||||
def test_execute_cell_rejects_text_mismatch_without_persisting_private_text(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
manifest = _manifest(tmp_path)
|
||||
output = {"environment": {"logical_cpus": 4, "sherpa_onnx_version": "1.13.6"}}
|
||||
spec = calibration.expand_automatic_cells(manifest, "e" * 64)[0]
|
||||
recording = manifest["recordings"][0]
|
||||
private_text = " СОВЕРШЕННО-СЕКРЕТНЫЙ-ТЕКСТ"
|
||||
Path(recording["asr_words"]["path"]).write_text(
|
||||
json.dumps([{"start": 0.0, "end": 0.5, "text": private_text}]),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
calibration,
|
||||
"run_worker",
|
||||
lambda *args, **kwargs: (
|
||||
{"metrics": {"wall_seconds": 1.0}, "segments": []},
|
||||
(
|
||||
"OfflineSpeakerDiarization: segmentation 0.300 s\n"
|
||||
"OfflineSpeakerDiarization: embedding 0.600 s\n"
|
||||
"OfflineSpeakerDiarization: clustering 0.100 s\n"
|
||||
"OfflineSpeakerDiarization: total 1.000 s, "
|
||||
"audio 10.000 s, RTF 0.100"
|
||||
),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(calibration, "read_reference_turns", lambda *args: [])
|
||||
|
||||
def evaluate(segments, recording, words, reference_turns):
|
||||
source = calibration.canonical_non_whitespace_sequence(words)
|
||||
assert private_text.strip() in source
|
||||
output = source[:-1]
|
||||
return {}, {
|
||||
"input_sha256": hashlib.sha256(source.encode()).hexdigest(),
|
||||
"output_sha256": hashlib.sha256(output.encode()).hexdigest(),
|
||||
"equal": False,
|
||||
"first_mismatch_index": len(output),
|
||||
}
|
||||
|
||||
monkeypatch.setattr(calibration, "evaluate_segments", evaluate)
|
||||
|
||||
with pytest.raises(RuntimeError, match="first_mismatch_index=") as error:
|
||||
calibration._execute_cell(
|
||||
spec,
|
||||
manifest,
|
||||
output,
|
||||
{recording["id"]: tmp_path / "decoded.wav"},
|
||||
tmp_path,
|
||||
)
|
||||
assert private_text.strip() not in str(error.value)
|
||||
|
||||
|
||||
def test_execute_cell_rejects_engine_audio_duration_mismatch(tmp_path, monkeypatch):
|
||||
manifest = _manifest(tmp_path)
|
||||
output = {"environment": {"logical_cpus": 4, "sherpa_onnx_version": "1.13.6"}}
|
||||
spec = calibration.expand_automatic_cells(manifest, "e" * 64)[0]
|
||||
recording = manifest["recordings"][0]
|
||||
monkeypatch.setattr(
|
||||
calibration,
|
||||
"run_worker",
|
||||
lambda *args, **kwargs: (
|
||||
{"metrics": {"wall_seconds": 1.0}, "segments": []},
|
||||
(
|
||||
"OfflineSpeakerDiarization: segmentation 0.300 s\n"
|
||||
"OfflineSpeakerDiarization: embedding 0.600 s\n"
|
||||
"OfflineSpeakerDiarization: clustering 0.100 s\n"
|
||||
"OfflineSpeakerDiarization: total 1.000 s, "
|
||||
"audio 9.000 s, RTF 0.111"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Длительность audio"):
|
||||
calibration._execute_cell(
|
||||
spec,
|
||||
manifest,
|
||||
output,
|
||||
{recording["id"]: tmp_path / "decoded.wav"},
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
|
||||
def test_main_fails_closed_when_a_cell_failed(tmp_path, monkeypatch):
|
||||
manifest = _manifest(tmp_path)
|
||||
for recording in manifest["recordings"]:
|
||||
recording.update(calibration.ORIGINAL_CORPUS[recording["id"]])
|
||||
manifest_path = tmp_path / "manifest.json"
|
||||
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||
output_path = tmp_path / "result.json"
|
||||
work_dir = tmp_path / "work"
|
||||
monkeypatch.setattr(calibration, "validate_manifest", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
calibration,
|
||||
"environment_snapshot",
|
||||
lambda: {
|
||||
**_environment(),
|
||||
"started_at_utc": "2026-08-29T00:00:00+00:00",
|
||||
"platform": "test",
|
||||
"cpu": "test",
|
||||
"logical_cpus": 4,
|
||||
"python_version": "3.13",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
calibration,
|
||||
"decode_clip",
|
||||
lambda recording, target: tmp_path / f"{recording['id']}.wav",
|
||||
)
|
||||
|
||||
def run_specs(specs, manifest, output, decoded, output_path, work_dir):
|
||||
for spec in specs:
|
||||
calibration._replace_cell(
|
||||
output, calibration._failed_cell(spec, RuntimeError("boom"))
|
||||
)
|
||||
calibration.save_output(output_path, output)
|
||||
|
||||
monkeypatch.setattr(calibration, "_run_specs", run_specs)
|
||||
|
||||
with pytest.raises(RuntimeError, match="незавершённых ячеек 15"):
|
||||
calibration.main(
|
||||
[
|
||||
"--manifest",
|
||||
str(manifest_path),
|
||||
"--output",
|
||||
str(output_path),
|
||||
"--work-dir",
|
||||
str(work_dir),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_main_fails_closed_for_complete_rectangle_without_mapped_purity(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
manifest = _manifest(tmp_path)
|
||||
for recording in manifest["recordings"]:
|
||||
recording.update(calibration.ORIGINAL_CORPUS[recording["id"]])
|
||||
manifest_path = tmp_path / "manifest.json"
|
||||
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||
output_path = tmp_path / "result.json"
|
||||
work_dir = tmp_path / "work"
|
||||
monkeypatch.setattr(calibration, "validate_manifest", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
calibration,
|
||||
"environment_snapshot",
|
||||
lambda: {
|
||||
**_environment(),
|
||||
"started_at_utc": "2026-08-29T00:00:00+00:00",
|
||||
"platform": "test",
|
||||
"cpu": "test",
|
||||
"logical_cpus": 4,
|
||||
"python_version": "3.13",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
calibration,
|
||||
"decode_clip",
|
||||
lambda recording, target: tmp_path / f"{recording['id']}.wav",
|
||||
)
|
||||
modes = []
|
||||
|
||||
def run_specs(specs, manifest, output, decoded, output_path, work_dir):
|
||||
modes.extend({spec["mode"] for spec in specs})
|
||||
expected = {
|
||||
recording["id"]: recording["expected_speakers"]
|
||||
for recording in manifest["recordings"]
|
||||
}
|
||||
for spec in specs:
|
||||
calibration._replace_cell(
|
||||
output,
|
||||
_complete_cell(spec, expected[spec["recording_id"]], purity=None),
|
||||
)
|
||||
calibration.save_output(output_path, output)
|
||||
|
||||
monkeypatch.setattr(calibration, "_run_specs", run_specs)
|
||||
|
||||
with pytest.raises(RuntimeError, match="свип непригоден"):
|
||||
calibration.main(
|
||||
[
|
||||
"--manifest",
|
||||
str(manifest_path),
|
||||
"--output",
|
||||
str(output_path),
|
||||
"--work-dir",
|
||||
str(work_dir),
|
||||
]
|
||||
)
|
||||
|
||||
output = json.loads(output_path.read_text(encoding="utf-8"))
|
||||
assert modes == ["automatic"]
|
||||
assert {cell["mode"] for cell in output["cells"]} == {"automatic"}
|
||||
assert output["threshold_selection"] == []
|
||||
|
||||
|
||||
def test_run_worker_fake_protocol_reports_failure_and_success(tmp_path):
|
||||
request = {"cell_id": "abc"}
|
||||
commands = []
|
||||
|
||||
def failed(command, **kwargs):
|
||||
commands.append(command)
|
||||
return SimpleNamespace(returncode=1, stdout="", stderr="worker failed\n")
|
||||
|
||||
with pytest.raises(RuntimeError, match="worker failed"):
|
||||
calibration.run_worker(request, tmp_path, runner=failed)
|
||||
|
||||
def success(command, **kwargs):
|
||||
commands.append(command)
|
||||
return SimpleNamespace(
|
||||
returncode=0,
|
||||
stdout='{"metrics":{},"segments":[]}',
|
||||
stderr="native log",
|
||||
)
|
||||
|
||||
payload, stderr = calibration.run_worker(request, tmp_path, runner=success)
|
||||
assert payload["segments"] == [] and stderr == "native log"
|
||||
for command in commands:
|
||||
assert command[:3] == [
|
||||
sys.executable,
|
||||
str(SCRIPT.resolve()),
|
||||
"--worker",
|
||||
]
|
||||
request_path = Path(command[3])
|
||||
assert request_path.is_file()
|
||||
assert json.loads(request_path.read_text(encoding="utf-8")) == request
|
||||
@@ -0,0 +1,677 @@
|
||||
import io
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from local_transcriber import experimental_diarization as runner
|
||||
|
||||
|
||||
def _run_shared_cell(monkeypatch, tmp_path, *, counters=None, drop_output_text=False):
|
||||
events = []
|
||||
active_interval = {"name": None}
|
||||
embeddings = np.asarray([[1.0, 0.0], [0.0, 1.0]], dtype=np.float32)
|
||||
work_counters = (
|
||||
{
|
||||
"embedding_jobs": 2,
|
||||
"embedding_run_calls": 2,
|
||||
"embedding_rows_dropped": 0,
|
||||
}
|
||||
if counters is None
|
||||
else counters
|
||||
)
|
||||
|
||||
class Diarization:
|
||||
def sort_by_start_time(self):
|
||||
return [
|
||||
SimpleNamespace(speaker=0, start=0.0, end=0.5),
|
||||
SimpleNamespace(speaker=1, start=0.5, end=1.0),
|
||||
]
|
||||
|
||||
class Engine:
|
||||
sample_rate = runner.SAMPLE_RATE
|
||||
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
|
||||
def prepare(self, samples, workers):
|
||||
events.append((active_interval["name"], f"prepare:{workers}"))
|
||||
return SimpleNamespace(
|
||||
is_terminal=False,
|
||||
embeddings=embeddings.copy(),
|
||||
row_mapping=((0, 0, [(0, 8_000)]), (1, 0, [(8_000, 16_000)])),
|
||||
counters=dict(work_counters),
|
||||
)
|
||||
|
||||
def finalize(self, prepared, labels):
|
||||
events.append((active_interval["name"], "finalize"))
|
||||
return Diarization()
|
||||
|
||||
class Clustering:
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
|
||||
def __call__(self, values):
|
||||
events.append((active_interval["name"], "cluster"))
|
||||
return np.asarray([0, 1], dtype=np.int32)
|
||||
|
||||
fake_sherpa = SimpleNamespace(
|
||||
OfflineSpeakerDiarization=Engine,
|
||||
FastClusteringConfig=lambda **values: SimpleNamespace(**values),
|
||||
FastClustering=Clustering,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "sherpa_onnx", fake_sherpa)
|
||||
monkeypatch.setattr(
|
||||
runner,
|
||||
"make_config",
|
||||
lambda cell, artifacts, provider_config_path: SimpleNamespace(
|
||||
validate=lambda: True
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
runner,
|
||||
"_decode_clip",
|
||||
lambda recording: np.zeros(runner.SAMPLE_RATE, dtype=np.float32),
|
||||
)
|
||||
|
||||
def estimate(values):
|
||||
events.append((active_interval["name"], "counter"))
|
||||
return {"status": "complete", "num_clusters": 2, "p": 3}
|
||||
|
||||
monkeypatch.setattr(runner, "estimate_nme", estimate)
|
||||
measurement_names = iter(("published", "validation"))
|
||||
|
||||
def measure(operation, process, interval_seconds):
|
||||
name = next(measurement_names)
|
||||
active_interval["name"] = name
|
||||
try:
|
||||
result = operation()
|
||||
finally:
|
||||
active_interval["name"] = None
|
||||
suffix = 0 if name == "published" else 1
|
||||
return result, {
|
||||
"wall_seconds": 10.0 + suffix,
|
||||
"user_cpu_seconds": 7.0 + suffix,
|
||||
"system_cpu_seconds": 1.0,
|
||||
"total_cpu_seconds": 8.0 + suffix,
|
||||
"average_cpu_cores": 0.8,
|
||||
"peak_rss_bytes": 100 + suffix,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(runner, "measure_call", measure)
|
||||
|
||||
@contextmanager
|
||||
def native_log():
|
||||
yield io.BytesIO()
|
||||
|
||||
monkeypatch.setattr(runner, "capture_native_stderr", native_log)
|
||||
monkeypatch.setattr(
|
||||
runner,
|
||||
"parse_prepare_timings",
|
||||
lambda log: {"segmentation_seconds": 0.2, "embedding_seconds": 0.1},
|
||||
)
|
||||
if drop_output_text:
|
||||
original_builder = runner.build_speaker_transcript
|
||||
|
||||
def build_without_last_word(words, intervals, duration):
|
||||
transcript = original_builder(words, intervals, duration)
|
||||
transcript.turns = transcript.turns[:-1]
|
||||
return transcript
|
||||
|
||||
monkeypatch.setattr(
|
||||
runner, "build_speaker_transcript", build_without_last_word
|
||||
)
|
||||
|
||||
words_path = tmp_path / "words.json"
|
||||
words_path.write_text(
|
||||
'[{"start": 0.1, "end": 0.4, "text": "Привет"}, '
|
||||
'{"start": 0.6, "end": 0.9, "text": "мир"}]',
|
||||
encoding="utf-8",
|
||||
)
|
||||
cell = {
|
||||
"name": "E1",
|
||||
"cell_id": "shared-cell",
|
||||
"counter": "nme",
|
||||
"window_shift_ratio": 0.1,
|
||||
"segmentation_artifact_id": "segmentation",
|
||||
"embedding_artifact_id": "embedding",
|
||||
"recording_ids": ["recording"],
|
||||
"clustering": {"threshold": 0.89, "num_clusters": None},
|
||||
"inference": {
|
||||
"mode": "shared-session",
|
||||
"outer_workers": 2,
|
||||
"session_count": 1,
|
||||
"intra_op_threads": 2,
|
||||
"inter_op_threads": 1,
|
||||
"batch_size": 1,
|
||||
},
|
||||
}
|
||||
request = {
|
||||
"manifest": {
|
||||
"rss_sample_interval_ms": 10,
|
||||
"artifacts": [],
|
||||
"recordings": [
|
||||
{
|
||||
"id": "recording",
|
||||
"path": str(tmp_path / "recording.wav"),
|
||||
"start": 0.0,
|
||||
"duration": 1.0,
|
||||
"expected_speakers": 2,
|
||||
"asr_words": {"path": str(words_path)},
|
||||
"reference": None,
|
||||
}
|
||||
],
|
||||
},
|
||||
"cell": cell,
|
||||
"work_dir": str(tmp_path),
|
||||
}
|
||||
return runner.run_cell(request), events
|
||||
|
||||
|
||||
def test_run_cell_shared_session_separates_measurement_from_validation(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
result, events = _run_shared_cell(monkeypatch, tmp_path)
|
||||
|
||||
recording = result["recordings"][0]
|
||||
assert result["aggregate"]["quality_passed"] is True
|
||||
assert recording["text_equal"] is True
|
||||
assert recording["prepared"]["inference"] == {
|
||||
"mode": "shared-session",
|
||||
"rows_equal_to_sequential": True,
|
||||
"finite_embeddings": True,
|
||||
"labels_equal_to_sequential": True,
|
||||
"final_equal_to_sequential": True,
|
||||
"max_abs_drift": 0.0,
|
||||
"min_cosine": 1.0,
|
||||
}
|
||||
assert recording["metrics"]["wall_seconds"] == 10.0
|
||||
assert recording["metrics"]["peak_rss_bytes"] == 100
|
||||
assert recording["metrics"]["validation_wall_seconds"] == 11.0
|
||||
assert recording["metrics"]["validation_peak_rss_bytes"] == 101
|
||||
assert events == [
|
||||
("published", "prepare:2"),
|
||||
("published", "counter"),
|
||||
("published", "cluster"),
|
||||
("published", "finalize"),
|
||||
("validation", "counter"),
|
||||
("validation", "cluster"),
|
||||
("validation", "prepare:1"),
|
||||
("validation", "counter"),
|
||||
("validation", "cluster"),
|
||||
("validation", "finalize"),
|
||||
]
|
||||
|
||||
|
||||
def test_run_cell_rejects_changed_output_text(monkeypatch, tmp_path):
|
||||
result, _ = _run_shared_cell(
|
||||
monkeypatch, tmp_path, drop_output_text=True
|
||||
)
|
||||
|
||||
assert result["recordings"][0]["text_equal"] is False
|
||||
assert result["aggregate"]["quality_passed"] is False
|
||||
|
||||
|
||||
def test_run_cell_rejects_missing_prepared_work_counters(monkeypatch, tmp_path):
|
||||
result, _ = _run_shared_cell(monkeypatch, tmp_path, counters={})
|
||||
|
||||
assert result["recordings"][0]["prepared"]["validation_passed"] is False
|
||||
assert result["aggregate"]["quality_passed"] is False
|
||||
|
||||
|
||||
def test_segmentation_window_count_includes_last_padded_window():
|
||||
assert runner.segmentation_window_count(160_000, 16_000) == 1
|
||||
assert runner.segmentation_window_count(160_001, 16_000) == 2
|
||||
assert runner.segmentation_window_count(176_000, 16_000) == 2
|
||||
|
||||
|
||||
def test_prepare_timing_parser_accepts_exactly_two_stages():
|
||||
log = (
|
||||
"OfflineSpeakerDiarization: segmentation 1.250 s\n"
|
||||
"OfflineSpeakerDiarization: embedding 2.500 s"
|
||||
)
|
||||
|
||||
assert runner.parse_prepare_timings(log) == {
|
||||
"segmentation_seconds": 1.25,
|
||||
"embedding_seconds": 2.5,
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="обе стадии"):
|
||||
runner.parse_prepare_timings("OfflineSpeakerDiarization: embedding 2.5 s")
|
||||
|
||||
|
||||
def test_known_counter_clusters_independent_embedding_copies():
|
||||
original = np.asarray([[1.0, 0.0], [0.9, 0.1], [0.0, 1.0]], dtype=np.float32)
|
||||
prepared = SimpleNamespace(embeddings=original, is_terminal=False)
|
||||
|
||||
class FakeClustering:
|
||||
calls = 0
|
||||
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
|
||||
def __call__(self, features):
|
||||
FakeClustering.calls += 1
|
||||
features[:] = -100
|
||||
return list(range(self.config.num_clusters)) + [0]
|
||||
|
||||
fake_sherpa = SimpleNamespace(
|
||||
FastClusteringConfig=lambda **values: SimpleNamespace(**values),
|
||||
FastClustering=FakeClustering,
|
||||
)
|
||||
cell = {
|
||||
"counter": "known",
|
||||
"clustering": {"threshold": 0.89},
|
||||
}
|
||||
|
||||
labels, counter, _ = runner._cluster_prepared(
|
||||
fake_sherpa, prepared, cell, expected_speakers=2
|
||||
)
|
||||
|
||||
assert labels.tolist() == [0, 1, 0]
|
||||
assert counter["num_clusters"] == 2
|
||||
assert FakeClustering.calls == 1
|
||||
validated = runner._validate_cluster_repeatability(
|
||||
fake_sherpa,
|
||||
prepared,
|
||||
cell,
|
||||
expected_speakers=2,
|
||||
labels=labels,
|
||||
counter_result=counter,
|
||||
)
|
||||
assert validated["repeatable_labels"] is True
|
||||
assert FakeClustering.calls == 2
|
||||
assert np.array_equal(prepared.embeddings, original)
|
||||
|
||||
|
||||
def _special_cell(mode, *, batch_size=1, sessions=1, outer=1, intra=4):
|
||||
return {
|
||||
"counter": "nme" if mode == "titanet-batch" else "known",
|
||||
"clustering": {"threshold": 0.89},
|
||||
"embedding_artifact_id": "embedding",
|
||||
"inference": {
|
||||
"mode": mode,
|
||||
"outer_workers": outer,
|
||||
"session_count": sessions,
|
||||
"intra_op_threads": intra,
|
||||
"inter_op_threads": 1,
|
||||
"batch_size": batch_size,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_special_inference_enforces_exact_thread_budgets():
|
||||
runner._validate_special_inference(
|
||||
_special_cell("separate-session", sessions=4, outer=4, intra=1), 4
|
||||
)
|
||||
runner._validate_special_inference(
|
||||
_special_cell("titanet-batch", batch_size=4), 4
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="P worker"):
|
||||
runner._validate_special_inference(
|
||||
_special_cell("separate-session", sessions=2, outer=4, intra=1), 4
|
||||
)
|
||||
with pytest.raises(ValueError, match="бюджет физических ядер"):
|
||||
runner._validate_special_inference(
|
||||
_special_cell("separate-session", sessions=4, outer=4, intra=2), 4
|
||||
)
|
||||
with pytest.raises(ValueError, match="batch 1 или 4"):
|
||||
runner._validate_special_inference(
|
||||
_special_cell("titanet-batch", batch_size=2), 4
|
||||
)
|
||||
|
||||
|
||||
def test_separate_sessions_restore_row_order_without_loss():
|
||||
class Stream:
|
||||
def __init__(self):
|
||||
self.samples = []
|
||||
|
||||
def accept_waveform(self, sample_rate, samples):
|
||||
assert sample_rate == runner.SAMPLE_RATE
|
||||
self.samples.extend(samples)
|
||||
|
||||
def input_finished(self):
|
||||
pass
|
||||
|
||||
class Extractor:
|
||||
def create_stream(self):
|
||||
return Stream()
|
||||
|
||||
def is_ready(self, stream):
|
||||
return bool(stream.samples)
|
||||
|
||||
def compute(self, stream):
|
||||
return [stream.samples[0], len(stream.samples)]
|
||||
|
||||
samples = np.arange(30, dtype=np.float32)
|
||||
mapping = [
|
||||
(0, 0, [(9, 12)]),
|
||||
(0, 1, [(1, 3)]),
|
||||
(1, 0, [(20, 24)]),
|
||||
(1, 1, [(5, 6)]),
|
||||
(2, 0, [(14, 19)]),
|
||||
]
|
||||
|
||||
embeddings = runner._compute_separate_embeddings(
|
||||
[Extractor(), Extractor()], mapping, samples, outer_workers=2
|
||||
)
|
||||
|
||||
assert embeddings.tolist() == [
|
||||
[9.0, 3.0],
|
||||
[1.0, 2.0],
|
||||
[20.0, 4.0],
|
||||
[5.0, 1.0],
|
||||
[14.0, 5.0],
|
||||
]
|
||||
assert np.isfinite(embeddings).all()
|
||||
|
||||
|
||||
def test_separate_runner_measures_validation_and_embedding_rss_separately(
|
||||
monkeypatch,
|
||||
):
|
||||
embeddings = np.asarray([[1.0, 0.0], [0.0, 1.0]], dtype=np.float32)
|
||||
prepared = SimpleNamespace(
|
||||
is_terminal=False,
|
||||
embeddings=embeddings.copy(),
|
||||
row_mapping=((0, 0, [(0, 5)]), (1, 0, [(5, 10)])),
|
||||
counters={
|
||||
"embedding_jobs": 2,
|
||||
"embedding_run_calls": 2,
|
||||
"embedding_rows_dropped": 0,
|
||||
},
|
||||
)
|
||||
events = []
|
||||
active_interval = {"name": None}
|
||||
|
||||
class Clustering:
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
|
||||
def __call__(self, values):
|
||||
events.append((active_interval["name"], "cluster"))
|
||||
return np.asarray([0, 1], dtype=np.int32)
|
||||
|
||||
fake_sherpa = SimpleNamespace(
|
||||
FastClusteringConfig=lambda **values: SimpleNamespace(**values),
|
||||
FastClustering=Clustering,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "sherpa_onnx", fake_sherpa)
|
||||
|
||||
def estimate(values):
|
||||
events.append((active_interval["name"], "counter"))
|
||||
return {"status": "complete", "num_clusters": 2, "p": 3}
|
||||
|
||||
monkeypatch.setattr(runner, "estimate_nme", estimate)
|
||||
monkeypatch.setattr(
|
||||
runner,
|
||||
"_compute_separate_embeddings",
|
||||
lambda extractors, mapping, samples, outer_workers: embeddings.copy(),
|
||||
)
|
||||
measurement_names = iter(("published", "embedding", "validation"))
|
||||
|
||||
def measure(operation, process, interval_seconds):
|
||||
name = next(measurement_names)
|
||||
previous = active_interval["name"]
|
||||
active_interval["name"] = name
|
||||
try:
|
||||
result = operation()
|
||||
finally:
|
||||
active_interval["name"] = previous
|
||||
values = {
|
||||
"published": (10.0, 100),
|
||||
"embedding": (3.0, 50),
|
||||
"validation": (4.0, 200),
|
||||
}
|
||||
wall, peak = values[name]
|
||||
return result, {
|
||||
"wall_seconds": wall,
|
||||
"user_cpu_seconds": wall / 2,
|
||||
"system_cpu_seconds": 1.0,
|
||||
"total_cpu_seconds": wall / 2 + 1.0,
|
||||
"average_cpu_cores": 0.6,
|
||||
"peak_rss_bytes": peak,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(runner, "measure_call", measure)
|
||||
|
||||
@contextmanager
|
||||
def native_log():
|
||||
yield io.BytesIO()
|
||||
|
||||
monkeypatch.setattr(runner, "capture_native_stderr", native_log)
|
||||
monkeypatch.setattr(
|
||||
runner,
|
||||
"parse_prepare_timings",
|
||||
lambda log: {"segmentation_seconds": 0.2, "embedding_seconds": 0.1},
|
||||
)
|
||||
|
||||
class Diarization:
|
||||
def sort_by_start_time(self):
|
||||
return [
|
||||
SimpleNamespace(speaker=0, start=0.0, end=0.5),
|
||||
SimpleNamespace(speaker=1, start=0.5, end=1.0),
|
||||
]
|
||||
|
||||
class Engine:
|
||||
def prepare(self, samples, workers):
|
||||
events.append((active_interval["name"], "prepare"))
|
||||
return prepared
|
||||
|
||||
def finalize(self, prepared_value, labels):
|
||||
events.append((active_interval["name"], "finalize"))
|
||||
return Diarization()
|
||||
|
||||
cell = _special_cell("separate-session", sessions=2, outer=2, intra=1)
|
||||
cell["counter"] = "nme"
|
||||
_, metrics, _, data = runner._run_separate_session_recording(
|
||||
Engine(),
|
||||
[object(), object()],
|
||||
np.zeros(10, dtype=np.float32),
|
||||
cell,
|
||||
{"expected_speakers": 2},
|
||||
0.01,
|
||||
)
|
||||
|
||||
assert metrics["peak_rss_bytes"] == 100
|
||||
assert metrics["embedding_peak_rss_bytes"] == 50
|
||||
assert metrics["validation_peak_rss_bytes"] == 200
|
||||
assert metrics["validation_wall_seconds"] == 4.0
|
||||
assert metrics["instrumented_pipeline_wall_seconds"] == 10.0
|
||||
assert "measured_validation_wall_seconds" not in metrics
|
||||
assert data["validation_passed"] is True
|
||||
assert events == [
|
||||
("published", "prepare"),
|
||||
("published", "counter"),
|
||||
("published", "cluster"),
|
||||
("published", "finalize"),
|
||||
("validation", "counter"),
|
||||
("validation", "cluster"),
|
||||
("validation", "counter"),
|
||||
("validation", "cluster"),
|
||||
("validation", "finalize"),
|
||||
]
|
||||
|
||||
|
||||
def test_titanet_batches_keep_variable_length_row_order():
|
||||
features = [
|
||||
np.full((3, 80), 10.0, dtype=np.float32),
|
||||
np.full((5, 80), 20.0, dtype=np.float32),
|
||||
np.full((2, 80), 30.0, dtype=np.float32),
|
||||
np.full((4, 80), 40.0, dtype=np.float32),
|
||||
np.full((6, 80), 50.0, dtype=np.float32),
|
||||
]
|
||||
|
||||
class Session:
|
||||
def __init__(self):
|
||||
self.length_batches = []
|
||||
|
||||
def run(self, output_names, inputs):
|
||||
assert output_names == ["embs"]
|
||||
values = inputs["audio_signal"]
|
||||
lengths = inputs["length"]
|
||||
self.length_batches.append(lengths.tolist())
|
||||
assert values.shape == (len(lengths), 80, max(lengths))
|
||||
return [
|
||||
np.asarray(
|
||||
[[values[index, 0, 0], length] for index, length in enumerate(lengths)],
|
||||
dtype=np.float32,
|
||||
)
|
||||
]
|
||||
|
||||
session = Session()
|
||||
batch4 = runner._infer_titanet_batches(session, features, batch_size=4)
|
||||
batch1 = runner._infer_titanet_batches(Session(), features, batch_size=1)
|
||||
|
||||
assert session.length_batches == [[3, 5, 2, 4], [6]]
|
||||
assert batch4.tolist() == batch1.tolist() == [
|
||||
[10.0, 3.0],
|
||||
[20.0, 5.0],
|
||||
[30.0, 2.0],
|
||||
[40.0, 4.0],
|
||||
[50.0, 6.0],
|
||||
]
|
||||
|
||||
|
||||
def test_titanet_runner_compares_batch_1_and_4_before_finalize(monkeypatch):
|
||||
embeddings = np.asarray(
|
||||
[[1.0, 0.0], [0.9, 0.1], [0.0, 1.0]], dtype=np.float32
|
||||
)
|
||||
prepared = SimpleNamespace(
|
||||
is_terminal=False,
|
||||
embeddings=embeddings.copy(),
|
||||
row_mapping=((0, 0, [(0, 4)]), (0, 1, [(4, 8)]), (1, 0, [(8, 12)])),
|
||||
counters={"embedding_jobs": 3, "embedding_run_calls": 3, "embedding_rows_dropped": 0},
|
||||
)
|
||||
|
||||
events = []
|
||||
active_interval = {"name": None}
|
||||
|
||||
class Clustering:
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
|
||||
def __call__(self, values):
|
||||
events.append((active_interval["name"], "cluster"))
|
||||
return np.asarray([0, 0, 1], dtype=np.int32)
|
||||
|
||||
fake_sherpa = SimpleNamespace(
|
||||
FastClusteringConfig=lambda **values: SimpleNamespace(**values),
|
||||
FastClustering=Clustering,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "sherpa_onnx", fake_sherpa)
|
||||
monkeypatch.setattr(
|
||||
runner,
|
||||
"estimate_nme",
|
||||
lambda values: (
|
||||
events.append((active_interval["name"], "counter"))
|
||||
or {"status": "complete", "num_clusters": 2, "p": 2}
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
runner,
|
||||
"_prepare_titanet_features",
|
||||
lambda mapping, samples: [
|
||||
np.ones((3, 80), dtype=np.float32),
|
||||
np.ones((4, 80), dtype=np.float32),
|
||||
np.ones((5, 80), dtype=np.float32),
|
||||
],
|
||||
)
|
||||
seen_batches = []
|
||||
|
||||
def infer(session, features, batch_size):
|
||||
seen_batches.append((active_interval["name"], batch_size))
|
||||
return embeddings.copy()
|
||||
|
||||
monkeypatch.setattr(runner, "_infer_titanet_batches", infer)
|
||||
measurement_names = iter(
|
||||
("published", "published-embedding", "validation", "validation-embedding")
|
||||
)
|
||||
|
||||
def measure(operation, process, interval_seconds):
|
||||
name = next(measurement_names)
|
||||
previous = active_interval["name"]
|
||||
active_interval["name"] = name
|
||||
try:
|
||||
result = operation()
|
||||
finally:
|
||||
active_interval["name"] = previous
|
||||
return result, {
|
||||
"wall_seconds": 1.0,
|
||||
"user_cpu_seconds": 0.7,
|
||||
"system_cpu_seconds": 0.1,
|
||||
"total_cpu_seconds": 0.8,
|
||||
"average_cpu_cores": 0.8,
|
||||
"peak_rss_bytes": 100,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(runner, "measure_call", measure)
|
||||
|
||||
@contextmanager
|
||||
def native_log():
|
||||
yield io.BytesIO()
|
||||
|
||||
monkeypatch.setattr(runner, "capture_native_stderr", native_log)
|
||||
monkeypatch.setattr(
|
||||
runner,
|
||||
"parse_prepare_timings",
|
||||
lambda log: {"segmentation_seconds": 0.2, "embedding_seconds": 0.4},
|
||||
)
|
||||
|
||||
class Diarization:
|
||||
def __init__(self, labels):
|
||||
self.labels = labels
|
||||
|
||||
def sort_by_start_time(self):
|
||||
return [
|
||||
SimpleNamespace(speaker=int(label), start=index, end=index + 1)
|
||||
for index, label in enumerate(self.labels)
|
||||
]
|
||||
|
||||
class Engine:
|
||||
def prepare(self, samples, workers):
|
||||
assert workers == 1
|
||||
events.append((active_interval["name"], "prepare"))
|
||||
return prepared
|
||||
|
||||
def finalize(self, prepared_value, labels):
|
||||
assert prepared_value is prepared
|
||||
events.append((active_interval["name"], "finalize"))
|
||||
return Diarization(labels)
|
||||
|
||||
cell = _special_cell("titanet-batch", batch_size=4)
|
||||
result, metrics, _, data = runner._run_titanet_batch_recording(
|
||||
Engine(),
|
||||
object(),
|
||||
np.arange(20, dtype=np.float32),
|
||||
cell,
|
||||
{"expected_speakers": 2},
|
||||
0.01,
|
||||
)
|
||||
|
||||
assert seen_batches == [("published-embedding", 4), ("validation-embedding", 1)]
|
||||
assert data["validation_passed"] is True
|
||||
assert data["inference"]["n_equal"] is True
|
||||
assert data["inference"]["labels_equal"] is True
|
||||
assert data["inference"]["final_equal"] is True
|
||||
assert data["counters"]["embedding_run_calls"] == 1
|
||||
assert metrics["measurement_kind"] == "composed-estimate"
|
||||
assert metrics["wall_seconds_is_composed"] is True
|
||||
assert metrics["instrumented_pipeline_wall_seconds"] == 1.0
|
||||
assert metrics["embedding_peak_rss_bytes"] == 100
|
||||
assert metrics["validation_wall_seconds"] == 1.0
|
||||
assert metrics["validation_peak_rss_bytes"] == 100
|
||||
assert events == [
|
||||
("published", "prepare"),
|
||||
("published", "counter"),
|
||||
("published", "cluster"),
|
||||
("published", "finalize"),
|
||||
("validation", "counter"),
|
||||
("validation", "cluster"),
|
||||
("validation", "counter"),
|
||||
("validation", "cluster"),
|
||||
("validation", "finalize"),
|
||||
]
|
||||
assert [item.speaker for item in result.sort_by_start_time()] == [0, 0, 1]
|
||||
+78
-3
@@ -1,6 +1,7 @@
|
||||
"""Tests for onnx-asr backend."""
|
||||
|
||||
import warnings
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -64,12 +65,12 @@ class TestEnsureModelAvailable:
|
||||
def with_timestamps(self):
|
||||
return self
|
||||
|
||||
def fake_load_model(*, model, quantization):
|
||||
def fake_load_model(*, model, quantization, providers):
|
||||
quantizations.append(quantization)
|
||||
return FakeAsrAdapter()
|
||||
|
||||
monkeypatch.setattr("onnx_asr.load_model", fake_load_model)
|
||||
monkeypatch.setattr("onnx_asr.load_vad", lambda model: None)
|
||||
monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kwargs: None)
|
||||
|
||||
backend = OnnxAsrBackend(compute_type_explicit=False)
|
||||
model_id = backend.ensure_model_available(
|
||||
@@ -87,6 +88,56 @@ class TestEnsureModelAvailable:
|
||||
|
||||
|
||||
class TestCreateModel:
|
||||
def test_cpu_provider_is_explicit_for_asr_and_vad(self, monkeypatch):
|
||||
"""Доступность CoreML/CUDA не меняет исполнение ASR и VAD."""
|
||||
monkeypatch.setattr(
|
||||
"onnxruntime.get_available_providers",
|
||||
lambda: ["CoreMLExecutionProvider", "CUDAExecutionProvider", "CPUExecutionProvider"],
|
||||
)
|
||||
load_asr = MagicMock()
|
||||
load_vad = MagicMock()
|
||||
monkeypatch.setattr("onnx_asr.load_model", load_asr)
|
||||
monkeypatch.setattr("onnx_asr.load_vad", load_vad)
|
||||
|
||||
OnnxAsrBackend().create_model("gigaam-v3-e2e-rnnt", "onnx", "int8")
|
||||
|
||||
load_asr.assert_called_once_with(
|
||||
model="gigaam-v3-e2e-rnnt",
|
||||
quantization="int8",
|
||||
providers=["CPUExecutionProvider"],
|
||||
)
|
||||
load_vad.assert_called_once_with("silero", providers=["CPUExecutionProvider"])
|
||||
|
||||
def test_thread_budget_reaches_asr_and_vad_sessions(self, monkeypatch):
|
||||
"""--threads задаёт intra_op_num_threads обеим сессиям через SessionOptions."""
|
||||
load_asr = MagicMock()
|
||||
load_vad = MagicMock()
|
||||
monkeypatch.setattr("onnx_asr.load_model", load_asr)
|
||||
monkeypatch.setattr("onnx_asr.load_vad", load_vad)
|
||||
|
||||
backend = OnnxAsrBackend()
|
||||
backend.create_model("gigaam-v3-e2e-rnnt", "onnx", "int8", cpu_threads=4)
|
||||
|
||||
asr_options = load_asr.call_args.kwargs["sess_options"]
|
||||
vad_options = load_vad.call_args.kwargs["sess_options"]
|
||||
assert asr_options.intra_op_num_threads == 4
|
||||
assert vad_options.intra_op_num_threads == 4
|
||||
assert backend.runtime_info()["intra_op_threads"] == "4"
|
||||
|
||||
def test_zero_thread_budget_keeps_library_session_defaults(self, monkeypatch):
|
||||
"""0 не передаёт SessionOptions: потоки остаются на усмотрение onnxruntime."""
|
||||
load_asr = MagicMock()
|
||||
load_vad = MagicMock()
|
||||
monkeypatch.setattr("onnx_asr.load_model", load_asr)
|
||||
monkeypatch.setattr("onnx_asr.load_vad", load_vad)
|
||||
|
||||
backend = OnnxAsrBackend()
|
||||
backend.create_model("gigaam-v3-e2e-rnnt", "onnx", "int8", cpu_threads=0)
|
||||
|
||||
assert "sess_options" not in load_asr.call_args.kwargs
|
||||
assert "sess_options" not in load_vad.call_args.kwargs
|
||||
assert backend.runtime_info()["intra_op_threads"] == "по умолчанию"
|
||||
|
||||
def test_wraps_vad_model_with_timestamps(self, monkeypatch):
|
||||
timestamped_model = object()
|
||||
|
||||
@@ -99,7 +150,7 @@ class TestCreateModel:
|
||||
return FakeVadAdapter()
|
||||
|
||||
monkeypatch.setattr("onnx_asr.load_model", lambda **kwargs: FakeAsrAdapter())
|
||||
monkeypatch.setattr("onnx_asr.load_vad", lambda model: object())
|
||||
monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kwargs: object())
|
||||
|
||||
model = OnnxAsrBackend().create_model("gigaam-v3-e2e-rnnt", "onnx", "int8")
|
||||
|
||||
@@ -127,6 +178,7 @@ class TestCreateModel:
|
||||
return self
|
||||
|
||||
monkeypatch.setattr("onnx_asr.load_model", fake_load_model)
|
||||
monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kwargs: None)
|
||||
|
||||
backend = OnnxAsrBackend()
|
||||
backend.actual_compute_type = "int8"
|
||||
@@ -637,3 +689,26 @@ class TestModelAliases:
|
||||
assert "--device cpu --model medium" in message
|
||||
assert "--device cuda --model large-v3-turbo" not in message
|
||||
assert "--device cpu --model large-v3-turbo" not in message
|
||||
|
||||
|
||||
class TestRuntimeInfo:
|
||||
def test_reports_versions_and_configured_providers(self, monkeypatch):
|
||||
"""Диагностика различает доступные providers и те, что заданы сессиям ASR и VAD."""
|
||||
monkeypatch.setattr(
|
||||
"onnxruntime.get_available_providers",
|
||||
lambda: ["CoreMLExecutionProvider", "CPUExecutionProvider"],
|
||||
)
|
||||
monkeypatch.setattr("onnx_asr.load_model", lambda **kwargs: MagicMock())
|
||||
monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kwargs: MagicMock())
|
||||
backend = OnnxAsrBackend()
|
||||
backend.actual_compute_type = "int8"
|
||||
backend.create_model("gigaam-v3-e2e-rnnt", "onnx", "int8")
|
||||
|
||||
info = backend.runtime_info()
|
||||
|
||||
assert info["onnxruntime"]
|
||||
assert info["onnx_asr"]
|
||||
assert info["available_providers"] == "CoreMLExecutionProvider, CPUExecutionProvider"
|
||||
assert info["asr_providers"] == "CPUExecutionProvider"
|
||||
assert info["vad_providers"] == "CPUExecutionProvider"
|
||||
assert info["quantization"] == "int8"
|
||||
@@ -222,6 +222,7 @@ def test_load_speaker_diarizer_keeps_corrupt_cache_when_download_is_invalid(
|
||||
|
||||
with (
|
||||
patch("huggingface_hub.cached_assets_path", return_value=tmp_path),
|
||||
patch.dict(sys.modules, {"sherpa_onnx": SimpleNamespace()}),
|
||||
patch("httpx.stream", return_value=FakeResponse()),
|
||||
pytest.raises(RuntimeError, match="Контрольная сумма"),
|
||||
):
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+471
-294
@@ -4,11 +4,12 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from local_transcriber.transcriber import (
|
||||
ExecutionRequest,
|
||||
Segment,
|
||||
Transcriber,
|
||||
TranscribeResult,
|
||||
_transcribe_file,
|
||||
cuda_error_hint,
|
||||
ensure_model_available,
|
||||
load_model,
|
||||
transcribe,
|
||||
)
|
||||
from local_transcriber.types import WordTimestampsUnavailableError
|
||||
@@ -16,6 +17,21 @@ from local_transcriber.types import WordTimestampsUnavailableError
|
||||
# === Helpers ===
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[RuntimeError("out of memory"), RuntimeError("CUDA unknown error")],
|
||||
)
|
||||
def test_unknown_error_does_not_suggest_cuda_installation(error):
|
||||
"""CPU OOM и неизвестная причина не получают совет по установке CUDA."""
|
||||
assert cuda_error_hint(error) is None
|
||||
|
||||
|
||||
def test_windows_loader_error_suggests_extra():
|
||||
"""OSError загрузчика DLL распознаётся без обёртки RuntimeError."""
|
||||
error = OSError("[WinError 126] Could not find module 'cublas64_12.dll'")
|
||||
assert "uv sync --extra cuda" in cuda_error_hint(error)
|
||||
|
||||
|
||||
def _make_result(
|
||||
count: int = 2,
|
||||
language: str = "ru",
|
||||
@@ -216,7 +232,7 @@ def test_transcribe_reports_status_transitions(mock_get_backend):
|
||||
on_status=statuses.append,
|
||||
)
|
||||
|
||||
# load_model reports init status, _transcribe_file reports transcribe status
|
||||
# Статусы подготовки и распознавания идут через один callback
|
||||
assert any("Инициализирую модель" in s for s in statuses)
|
||||
assert any("Транскрибирую" in s for s in statuses)
|
||||
|
||||
@@ -279,101 +295,6 @@ def test_transcribe_strict_cuda_error_during_transcription(mock_get_backend):
|
||||
)
|
||||
|
||||
|
||||
# === load_model() tests ===
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_load_model_cuda_fallback(mock_get_backend):
|
||||
cuda_backend = _make_backend(create_model_error=RuntimeError("CUDA out of memory"))
|
||||
cpu_model = MagicMock()
|
||||
cpu_backend = _make_backend(model=cpu_model, model_path="/mock/cpu/model")
|
||||
|
||||
def backend_for_device(device, **kwargs):
|
||||
return cuda_backend if device == "cuda" else cpu_backend
|
||||
|
||||
mock_get_backend.side_effect = backend_for_device
|
||||
|
||||
with pytest.warns(UserWarning, match="Переключение на CPU"):
|
||||
model, actual_device, backend, model_path = load_model("tiny", "cuda", "int8")
|
||||
|
||||
assert actual_device == "cpu"
|
||||
assert model is cpu_model
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_load_model_strict_raises(mock_get_backend):
|
||||
backend = _make_backend(create_model_error=RuntimeError("CUDA out of memory"))
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
with pytest.raises(RuntimeError, match="CUDA out of memory"):
|
||||
load_model("tiny", "cuda", "int8", strict_device=True)
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_load_model_returns_backend_and_path(mock_get_backend):
|
||||
backend = _make_backend(model_path="/mock/model/path")
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
model, actual_device, returned_backend, model_path = load_model(
|
||||
"tiny", "cpu", "int8"
|
||||
)
|
||||
|
||||
assert returned_backend is backend
|
||||
assert model_path == "/mock/model/path"
|
||||
assert actual_device == "cpu"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_load_model_passes_cpu_threads_to_backend(mock_get_backend):
|
||||
backend = _make_backend(model_path="/mock/model/path")
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
load_model("tiny", "cpu", "int8", cpu_threads=8)
|
||||
|
||||
assert backend.create_model.call_args.kwargs["cpu_threads"] == 8
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_load_model_fallback_preserves_cpu_threads(mock_get_backend):
|
||||
cuda_backend = _make_backend(create_model_error=RuntimeError("CUDA out of memory"))
|
||||
cpu_model = MagicMock()
|
||||
cpu_backend = _make_backend(model=cpu_model, model_path="/mock/cpu/model")
|
||||
|
||||
def backend_for_device(device, **kwargs):
|
||||
return cuda_backend if device == "cuda" else cpu_backend
|
||||
|
||||
mock_get_backend.side_effect = backend_for_device
|
||||
|
||||
with pytest.warns(UserWarning, match="Переключение на CPU"):
|
||||
load_model("tiny", "cuda", "int8", cpu_threads=6)
|
||||
|
||||
assert cuda_backend.create_model.call_args.kwargs["cpu_threads"] == 6
|
||||
assert cpu_backend.create_model.call_args.kwargs["cpu_threads"] == 6
|
||||
|
||||
|
||||
# === _transcribe_file() tests ===
|
||||
|
||||
|
||||
def test__transcribe_file_basic():
|
||||
result_data = _make_result(count=2)
|
||||
backend = _make_backend(transcribe_result=result_data)
|
||||
|
||||
tfr = _transcribe_file(
|
||||
model=MagicMock(),
|
||||
actual_device="cpu",
|
||||
backend=backend,
|
||||
model_path="/mock/model",
|
||||
file_path=Path("test.mp3"),
|
||||
model_name="tiny",
|
||||
compute_type="int8",
|
||||
)
|
||||
|
||||
assert len(tfr.result.segments) == 2
|
||||
assert tfr.actual_device == "cpu"
|
||||
assert tfr.backend is backend
|
||||
assert tfr.model_path == "/mock/model"
|
||||
|
||||
|
||||
# === ensure_model_available() tests (через FasterWhisperBackend) ===
|
||||
|
||||
|
||||
@@ -476,202 +397,6 @@ def test_ensure_model_available_rejects_incomplete_local_directory(tmp_path):
|
||||
ensure_model_available(str(model_dir))
|
||||
|
||||
|
||||
# === Cross-backend fallback (openvino → cpu) ===
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_load_model_openvino_gpu_fallback_to_cpu(mock_get_backend):
|
||||
"""OpenVINO GPU ошибка при init → fallback на CPU (FasterWhisper)."""
|
||||
ov_backend = _make_backend(
|
||||
create_model_error=RuntimeError("OpenVINO model load failed"),
|
||||
)
|
||||
cpu_model = MagicMock()
|
||||
cpu_backend = _make_backend(model=cpu_model, model_path="/mock/cpu/model")
|
||||
|
||||
def backend_for_device(device, **kwargs):
|
||||
return ov_backend if device == "openvino-gpu" else cpu_backend
|
||||
|
||||
mock_get_backend.side_effect = backend_for_device
|
||||
|
||||
with pytest.warns(UserWarning, match="Переключение на CPU"):
|
||||
model, actual_device, backend, model_path = load_model(
|
||||
"medium",
|
||||
"openvino-gpu",
|
||||
"fp16",
|
||||
)
|
||||
|
||||
assert actual_device == "cpu"
|
||||
assert model is cpu_model
|
||||
assert backend is cpu_backend
|
||||
assert model_path == "/mock/cpu/model"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_load_model_openvino_cpu_fallback_to_cpu(mock_get_backend):
|
||||
"""OpenVINO CPU ошибка при init → fallback на CPU (FasterWhisper)."""
|
||||
ov_backend = _make_backend(
|
||||
create_model_error=RuntimeError("OpenVINO model load failed"),
|
||||
)
|
||||
cpu_model = MagicMock()
|
||||
cpu_backend = _make_backend(model=cpu_model, model_path="/mock/cpu/model")
|
||||
|
||||
def backend_for_device(device, **kwargs):
|
||||
return ov_backend if device == "openvino-cpu" else cpu_backend
|
||||
|
||||
mock_get_backend.side_effect = backend_for_device
|
||||
|
||||
with pytest.warns(UserWarning, match="Переключение на CPU"):
|
||||
model, actual_device, backend, model_path = load_model(
|
||||
"medium",
|
||||
"openvino-cpu",
|
||||
"int8",
|
||||
)
|
||||
|
||||
assert actual_device == "cpu"
|
||||
assert model is cpu_model
|
||||
assert backend is cpu_backend
|
||||
assert model_path == "/mock/cpu/model"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_transcribe_file_openvino_gpu_midstream_fallback(mock_get_backend):
|
||||
"""OpenVINO GPU ошибка при транскрипции → fallback на CPU."""
|
||||
ov_backend = _make_backend(
|
||||
transcribe_error=RuntimeError("OpenVINO inference error"),
|
||||
)
|
||||
cpu_backend = _make_backend(
|
||||
transcribe_result=_make_result(count=2, device_used="cpu"),
|
||||
model_path="/mock/cpu/model",
|
||||
)
|
||||
|
||||
def backend_for_device(device, **kwargs):
|
||||
return ov_backend if device.startswith("openvino") else cpu_backend
|
||||
|
||||
mock_get_backend.side_effect = backend_for_device
|
||||
|
||||
with pytest.warns(UserWarning, match="Переключение на CPU"):
|
||||
tfr = _transcribe_file(
|
||||
model=MagicMock(),
|
||||
actual_device="openvino-gpu",
|
||||
backend=ov_backend,
|
||||
model_path="/mock/ov/model",
|
||||
file_path=Path("test.mp3"),
|
||||
model_name="medium",
|
||||
compute_type="fp16",
|
||||
)
|
||||
|
||||
assert tfr.actual_device == "cpu"
|
||||
assert tfr.backend is cpu_backend
|
||||
assert tfr.model_path == "/mock/cpu/model"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_transcribe_file_does_not_fallback_for_missing_word_timestamps(
|
||||
mock_get_backend,
|
||||
):
|
||||
ov_backend = _make_backend(
|
||||
transcribe_error=WordTimestampsUnavailableError("нет таймкодов"),
|
||||
)
|
||||
|
||||
with pytest.raises(WordTimestampsUnavailableError, match="нет таймкодов"):
|
||||
_transcribe_file(
|
||||
model=MagicMock(),
|
||||
actual_device="openvino-gpu",
|
||||
backend=ov_backend,
|
||||
model_path="/mock/ov/model",
|
||||
file_path=Path("test.mp3"),
|
||||
model_name="medium",
|
||||
compute_type="fp16",
|
||||
)
|
||||
|
||||
mock_get_backend.assert_not_called()
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_transcribe_file_midstream_fallback_preserves_cpu_threads(mock_get_backend):
|
||||
ov_backend = _make_backend(
|
||||
transcribe_error=RuntimeError("OpenVINO inference error"),
|
||||
)
|
||||
cpu_backend = _make_backend(
|
||||
transcribe_result=_make_result(count=2, device_used="cpu"),
|
||||
model_path="/mock/cpu/model",
|
||||
)
|
||||
|
||||
def backend_for_device(device, **kwargs):
|
||||
return ov_backend if device.startswith("openvino") else cpu_backend
|
||||
|
||||
mock_get_backend.side_effect = backend_for_device
|
||||
|
||||
with pytest.warns(UserWarning, match="Переключение на CPU"):
|
||||
_transcribe_file(
|
||||
model=MagicMock(),
|
||||
actual_device="openvino-gpu",
|
||||
backend=ov_backend,
|
||||
model_path="/mock/ov/model",
|
||||
file_path=Path("test.mp3"),
|
||||
model_name="medium",
|
||||
compute_type="fp16",
|
||||
cpu_threads=6,
|
||||
)
|
||||
|
||||
assert cpu_backend.create_model.call_args.kwargs["cpu_threads"] == 6
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_openvino_gpu_strict_device_no_fallback(mock_get_backend):
|
||||
"""strict_device=True + OpenVINO GPU ошибка → raise."""
|
||||
backend = _make_backend(
|
||||
create_model_error=RuntimeError("OpenVINO model load failed"),
|
||||
)
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
with pytest.raises(RuntimeError, match="OpenVINO"):
|
||||
load_model("medium", "openvino-gpu", "fp16", strict_device=True)
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_openvino_runtime_error_triggers_fallback(mock_get_backend):
|
||||
"""Любой RuntimeError от OpenVINO бэкенда → fallback."""
|
||||
ov_backend = _make_backend(
|
||||
create_model_error=RuntimeError("Exception from src/inference/..."),
|
||||
)
|
||||
cpu_backend = _make_backend(model_path="/mock/cpu/model")
|
||||
|
||||
def backend_for_device(device, **kwargs):
|
||||
return ov_backend if device.startswith("openvino") else cpu_backend
|
||||
|
||||
mock_get_backend.side_effect = backend_for_device
|
||||
|
||||
with pytest.warns(UserWarning, match="Переключение на CPU"):
|
||||
_, actual_device, _, _ = load_model("medium", "openvino-cpu", "int8")
|
||||
|
||||
assert actual_device == "cpu"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_load_model_resolves_openvino_gpu_device(mock_get_backend):
|
||||
"""load_model обновляет actual_device по backend.actual_ov_device."""
|
||||
backend = _make_backend()
|
||||
backend.actual_ov_device = "GPU"
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
_, actual_device, _, _ = load_model("medium", "openvino-cpu", "int8")
|
||||
|
||||
assert actual_device == "openvino-gpu"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_load_model_resolves_openvino_cpu_device(mock_get_backend):
|
||||
"""load_model обновляет actual_device по backend.actual_ov_device."""
|
||||
backend = _make_backend()
|
||||
backend.actual_ov_device = "CPU"
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
_, actual_device, _, _ = load_model("medium", "openvino-gpu", "fp16")
|
||||
|
||||
assert actual_device == "openvino-cpu"
|
||||
|
||||
|
||||
def test_ensure_model_available_openvino_default_compute_type():
|
||||
"""ensure_model_available(device='openvino-cpu') без compute_type не падает."""
|
||||
from local_transcriber.backends.openvino import OpenVINOBackend
|
||||
@@ -681,3 +406,455 @@ def test_ensure_model_available_openvino_default_compute_type():
|
||||
repo, ct = backend._resolve_repo("medium", "int8")
|
||||
assert repo == "OpenVINO/whisper-medium-int8-ov"
|
||||
assert ct == "int8"
|
||||
|
||||
|
||||
# === Transcriber: module выполнения ===
|
||||
|
||||
|
||||
def _make_run_backend(**kwargs):
|
||||
"""Fake adapter для external interface: runtime_info возвращает словарь."""
|
||||
backend = _make_backend(**kwargs)
|
||||
backend.runtime_info.return_value = {}
|
||||
backend.engine = "fake-engine"
|
||||
backend.word_timestamps_available = True
|
||||
backend.actual_compute_type = None
|
||||
backend.actual_ov_device = None
|
||||
return backend
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_loads_model_once_for_two_files(mock_get_backend):
|
||||
"""Два файла переиспользуют одну загрузку модели."""
|
||||
backend = _make_run_backend()
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
run = Transcriber(ExecutionRequest(device="cpu", model="tiny", compute_type="int8"))
|
||||
first = run.transcribe(Path("a.mp3"))
|
||||
second = run.transcribe(Path("b.mp3"))
|
||||
|
||||
assert backend.create_model.call_count == 1
|
||||
assert backend.transcribe.call_count == 2
|
||||
assert first.device_used == "cpu"
|
||||
assert second.device_used == "cpu"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_does_not_load_model_before_first_file(mock_get_backend):
|
||||
"""Создание module без файлов не загружает модель."""
|
||||
mock_get_backend.return_value = _make_run_backend()
|
||||
|
||||
Transcriber(ExecutionRequest(device="cpu", model="tiny", compute_type="int8"))
|
||||
|
||||
mock_get_backend.assert_not_called()
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_auto_resolves_to_onnx_cpu_with_device_defaults(mock_get_backend):
|
||||
"""auto → ONNX CPU и его модель/квантизация по умолчанию; сведения различают запрошенное и выбранное."""
|
||||
backend = _make_run_backend()
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
info = Transcriber(ExecutionRequest()).prepare()
|
||||
|
||||
mock_get_backend.assert_called_once_with("onnx", compute_type_explicit=False)
|
||||
backend.ensure_model_available.assert_called_once_with(
|
||||
"gigaam-v3-e2e-rnnt", "int8", None
|
||||
)
|
||||
assert info.requested_device == "auto"
|
||||
assert info.device == "onnx"
|
||||
assert info.model == "gigaam-v3-e2e-rnnt"
|
||||
assert info.compute_type == "int8"
|
||||
assert info.description == "ONNX (CPU)"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_falls_back_to_cpu_at_load_and_keeps_state_for_next_file(
|
||||
mock_get_backend,
|
||||
):
|
||||
"""Не-strict CUDA-ошибка при загрузке → CPU; следующий файл идёт на CPU без перезагрузки."""
|
||||
cuda_backend = _make_run_backend(
|
||||
create_model_error=RuntimeError("CUDA out of memory")
|
||||
)
|
||||
cpu_backend = _make_run_backend()
|
||||
mock_get_backend.side_effect = lambda device, **_: (
|
||||
cuda_backend if device == "cuda" else cpu_backend
|
||||
)
|
||||
|
||||
run = Transcriber(
|
||||
ExecutionRequest(
|
||||
device="cuda",
|
||||
model="tiny",
|
||||
compute_type="int8",
|
||||
strict_device=False,
|
||||
cpu_threads=3,
|
||||
)
|
||||
)
|
||||
with pytest.warns(UserWarning, match="Переключение на CPU"):
|
||||
first = run.transcribe(Path("a.mp3"))
|
||||
second = run.transcribe(Path("b.mp3"))
|
||||
|
||||
assert first.device_used == "cpu"
|
||||
assert second.device_used == "cpu"
|
||||
assert cpu_backend.create_model.call_count == 1
|
||||
assert cpu_backend.create_model.call_args.kwargs["cpu_threads"] == 3
|
||||
assert run.execution.resolved_device == "cuda"
|
||||
assert run.execution.device == "cpu"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_strict_device_raises_without_fallback(mock_get_backend):
|
||||
"""Явный device (strict по умолчанию) не подменяется другим исполнением."""
|
||||
mock_get_backend.return_value = _make_run_backend(
|
||||
create_model_error=RuntimeError("CUDA driver version is insufficient")
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="driver version"):
|
||||
Transcriber(
|
||||
ExecutionRequest(device="cuda", model="tiny", compute_type="int8")
|
||||
).prepare()
|
||||
|
||||
assert mock_get_backend.call_count == 1
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_midstream_fallback_reloads_once_and_continues_batch(mock_get_backend):
|
||||
"""CUDA-ошибка во время распознавания → повтор файла на CPU; батч продолжается на CPU."""
|
||||
cuda_backend = _make_run_backend(
|
||||
transcribe_error=RuntimeError("CUBLAS_STATUS_ALLOC_FAILED")
|
||||
)
|
||||
cpu_backend = _make_run_backend()
|
||||
mock_get_backend.side_effect = lambda device, **_: (
|
||||
cuda_backend if device == "cuda" else cpu_backend
|
||||
)
|
||||
|
||||
run = Transcriber(
|
||||
ExecutionRequest(
|
||||
device="cuda", model="tiny", compute_type="int8", strict_device=False
|
||||
)
|
||||
)
|
||||
with pytest.warns(UserWarning, match="Переключение на CPU и повтор"):
|
||||
first = run.transcribe(Path("a.mp3"))
|
||||
second = run.transcribe(Path("b.mp3"))
|
||||
|
||||
assert first.device_used == "cpu"
|
||||
assert second.device_used == "cpu"
|
||||
assert cpu_backend.create_model.call_count == 1
|
||||
assert cpu_backend.transcribe.call_count == 2
|
||||
assert run.execution.device == "cpu"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_ordinary_file_error_does_not_change_state(mock_get_backend):
|
||||
"""Ошибка файла (не движка) пробрасывается, модель и исполнение остаются прежними."""
|
||||
backend = _make_run_backend()
|
||||
backend.transcribe.side_effect = [
|
||||
ValueError("Не удалось декодировать"),
|
||||
_make_result(),
|
||||
]
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
run = Transcriber(
|
||||
ExecutionRequest(
|
||||
device="cuda", model="tiny", compute_type="int8", strict_device=False
|
||||
)
|
||||
)
|
||||
with pytest.raises(ValueError, match="декодировать"):
|
||||
run.transcribe(Path("bad.mp3"))
|
||||
result = run.transcribe(Path("good.mp3"))
|
||||
|
||||
assert result.device_used == "cuda"
|
||||
assert backend.create_model.call_count == 1
|
||||
assert mock_get_backend.call_count == 1
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_word_timestamps_error_is_not_a_fallback_reason(mock_get_backend):
|
||||
"""Нарушение пословного контракта не считается ошибкой движка."""
|
||||
mock_get_backend.return_value = _make_run_backend(
|
||||
transcribe_error=WordTimestampsUnavailableError("нет слов")
|
||||
)
|
||||
|
||||
run = Transcriber(
|
||||
ExecutionRequest(
|
||||
device="cuda", model="tiny", compute_type="int8", strict_device=False
|
||||
)
|
||||
)
|
||||
with pytest.raises(WordTimestampsUnavailableError):
|
||||
run.transcribe(Path("a.mp3"))
|
||||
|
||||
assert mock_get_backend.call_count == 1
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_requires_word_timestamps_before_first_file(mock_get_backend):
|
||||
"""Требование пословного контракта проверяется при подготовке, ASR не запускается."""
|
||||
backend = _make_run_backend()
|
||||
backend.word_timestamps_available = False
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
run = Transcriber(
|
||||
ExecutionRequest(
|
||||
device="onnx", model="some/raw-model", require_word_timestamps=True
|
||||
)
|
||||
)
|
||||
with pytest.raises(ValueError, match="пословные таймкоды"):
|
||||
run.transcribe(Path("a.mp3"))
|
||||
|
||||
backend.transcribe.assert_not_called()
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_reports_actual_openvino_device(mock_get_backend):
|
||||
"""openvino-gpu, выбранный до загрузки, уточняется по фактическому устройству OpenVINO."""
|
||||
backend = _make_run_backend()
|
||||
backend.actual_ov_device = "CPU"
|
||||
backend.actual_compute_type = "int8"
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
with patch(
|
||||
"local_transcriber.transcriber.detect_device", return_value="openvino-gpu"
|
||||
):
|
||||
info = Transcriber(
|
||||
ExecutionRequest(device="openvino", model="medium")
|
||||
).prepare()
|
||||
|
||||
assert info.requested_device == "openvino"
|
||||
assert info.resolved_device == "openvino-gpu"
|
||||
assert info.device == "openvino-cpu"
|
||||
assert info.compute_type == "int8"
|
||||
assert info.description == "OpenVINO (CPU)"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_gpu_name", return_value="RTX 3060")
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_exposes_runtime_and_gpu_name_from_module_data(mock_get_backend, _gpu):
|
||||
"""Сведения о runtime берутся из adapter'а, имя GPU — из драйвера, потоки — из запроса."""
|
||||
backend = _make_run_backend()
|
||||
backend.runtime_info.return_value = {"ctranslate2": "4.5.0"}
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
info = Transcriber(
|
||||
ExecutionRequest(
|
||||
device="cuda", model="tiny", compute_type="float16", cpu_threads=4
|
||||
)
|
||||
).prepare()
|
||||
|
||||
assert info.description == "CUDA (RTX 3060)"
|
||||
assert info.runtime == {"ctranslate2": "4.5.0"}
|
||||
assert info.cpu_threads == 4
|
||||
backend.create_model.assert_called_once_with(
|
||||
"/mock/model", "cuda", "float16", cpu_threads=4
|
||||
)
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_passes_auto_language_as_none(mock_get_backend):
|
||||
backend = _make_run_backend()
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
Transcriber(
|
||||
ExecutionRequest(device="cpu", model="tiny", language="auto")
|
||||
).transcribe(Path("a.mp3"))
|
||||
|
||||
assert backend.transcribe.call_args[0][2] is None
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_public_transcribe_uses_the_same_execution_module(mock_get_backend):
|
||||
"""transcribe() без параметров идёт тем же путём, что CLI по умолчанию: ONNX CPU."""
|
||||
backend = _make_run_backend(transcribe_result=_make_result(count=1))
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
result = transcribe(Path("a.mp3"))
|
||||
|
||||
mock_get_backend.assert_called_once_with("onnx", compute_type_explicit=False)
|
||||
backend.ensure_model_available.assert_called_once_with(
|
||||
"gigaam-v3-e2e-rnnt", "int8", None
|
||||
)
|
||||
assert result.device_used == "onnx"
|
||||
assert len(result.segments) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("device", "ov_device", "gpu_name", "intel_name", "expected"),
|
||||
[
|
||||
("cpu", None, None, None, "CPU"),
|
||||
("onnx", None, "RTX 3060", None, "ONNX (CPU)"),
|
||||
("cuda", None, "RTX 3060", None, "CUDA (RTX 3060)"),
|
||||
("cuda", None, None, None, "CUDA (Unknown GPU)"),
|
||||
("openvino-gpu", "GPU", None, "Intel Arc 140T", "OpenVINO (Intel Arc 140T)"),
|
||||
("openvino-gpu", "GPU", None, None, "OpenVINO (Intel GPU)"),
|
||||
("openvino-cpu", "CPU", None, None, "OpenVINO (CPU)"),
|
||||
],
|
||||
)
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_description_names_hardware_only_from_driver_data(
|
||||
mock_get_backend, device, ov_device, gpu_name, intel_name, expected
|
||||
):
|
||||
"""Строка исполнения для шапки: ONNX не выдаётся за CPU faster-whisper, GPU — по драйверу."""
|
||||
backend = _make_run_backend()
|
||||
backend.actual_ov_device = ov_device
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
with (
|
||||
patch("local_transcriber.transcriber.get_gpu_name", return_value=gpu_name),
|
||||
patch("local_transcriber.transcriber.get_intel_gpu_name", return_value=intel_name),
|
||||
):
|
||||
info = Transcriber(ExecutionRequest(device=device, model="m")).prepare()
|
||||
|
||||
assert info.description == expected
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_openvino_runtime_error_falls_back_when_not_strict(mock_get_backend):
|
||||
"""Любой RuntimeError OpenVINO считается ошибкой движка: не-strict запуск уходит на CPU."""
|
||||
ov_backend = _make_run_backend(
|
||||
create_model_error=RuntimeError("Exception from src/inference/src/core.cpp")
|
||||
)
|
||||
cpu_backend = _make_run_backend()
|
||||
mock_get_backend.side_effect = lambda device, **_: (
|
||||
ov_backend if device.startswith("openvino") else cpu_backend
|
||||
)
|
||||
|
||||
run = Transcriber(
|
||||
ExecutionRequest(device="openvino-gpu", model="medium", strict_device=False)
|
||||
)
|
||||
with pytest.warns(UserWarning, match="Переключение на CPU"):
|
||||
result = run.transcribe(Path("a.mp3"))
|
||||
|
||||
assert result.device_used == "cpu"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_openvino_strict_raises_without_fallback(mock_get_backend):
|
||||
mock_get_backend.return_value = _make_run_backend(
|
||||
create_model_error=RuntimeError("GPU plugin failed")
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="GPU plugin"):
|
||||
Transcriber(ExecutionRequest(device="openvino-gpu", model="medium")).prepare()
|
||||
|
||||
assert mock_get_backend.call_count == 1
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_reports_openvino_gpu_chosen_for_cpu_request(mock_get_backend):
|
||||
"""Фактическое устройство OpenVINO важнее запрошенного и в обратную сторону."""
|
||||
backend = _make_run_backend()
|
||||
backend.actual_ov_device = "GPU"
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
info = Transcriber(ExecutionRequest(device="openvino-cpu", model="medium")).prepare()
|
||||
|
||||
assert info.resolved_device == "openvino-cpu"
|
||||
assert info.device == "openvino-gpu"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_fallback_rederives_implicit_compute_type_for_cpu(mock_get_backend):
|
||||
"""Неявный compute_type следует за фактическим устройством: CPU не получает float16."""
|
||||
cuda_backend = _make_run_backend(create_model_error=RuntimeError("CUDA error"))
|
||||
cpu_backend = _make_run_backend()
|
||||
mock_get_backend.side_effect = lambda device, **_: (
|
||||
cuda_backend if device == "cuda" else cpu_backend
|
||||
)
|
||||
|
||||
run = Transcriber(ExecutionRequest(device="cuda", strict_device=False))
|
||||
with pytest.warns(UserWarning, match="Переключение на CPU"):
|
||||
info = run.prepare()
|
||||
|
||||
cuda_backend.ensure_model_available.assert_called_once_with("medium", "float16", None)
|
||||
cpu_backend.ensure_model_available.assert_called_once_with("medium", "float32", None)
|
||||
assert info.compute_type == "float32"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_fallback_keeps_explicit_compute_type(mock_get_backend):
|
||||
cuda_backend = _make_run_backend(create_model_error=RuntimeError("CUDA error"))
|
||||
cpu_backend = _make_run_backend()
|
||||
mock_get_backend.side_effect = lambda device, **_: (
|
||||
cuda_backend if device == "cuda" else cpu_backend
|
||||
)
|
||||
|
||||
run = Transcriber(
|
||||
ExecutionRequest(device="cuda", compute_type="int8", strict_device=False)
|
||||
)
|
||||
with pytest.warns(UserWarning):
|
||||
run.prepare()
|
||||
|
||||
cpu_backend.ensure_model_available.assert_called_once_with("medium", "int8", None)
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_failed_capability_check_does_not_mark_module_prepared(mock_get_backend):
|
||||
"""После отказа по пословному контракту повторный вызов не запускает ASR."""
|
||||
backend = _make_run_backend()
|
||||
backend.word_timestamps_available = False
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
run = Transcriber(
|
||||
ExecutionRequest(device="onnx", model="some/raw-model", require_word_timestamps=True)
|
||||
)
|
||||
with pytest.raises(ValueError, match="пословные таймкоды"):
|
||||
run.prepare()
|
||||
with pytest.raises(ValueError, match="пословные таймкоды"):
|
||||
run.transcribe(Path("a.mp3"))
|
||||
|
||||
backend.transcribe.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("device", "model", "compute_type"),
|
||||
[
|
||||
("cpu", "medium", "float32"),
|
||||
("cuda", "medium", "float16"),
|
||||
("openvino-cpu", "medium", "int8"),
|
||||
("openvino-gpu", "medium", "int8"),
|
||||
("onnx", "gigaam-v3-e2e-rnnt", "int8"),
|
||||
],
|
||||
)
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_applies_device_defaults_when_request_leaves_them_empty(
|
||||
mock_get_backend, device, model, compute_type
|
||||
):
|
||||
backend = _make_run_backend()
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
Transcriber(ExecutionRequest(device=device)).prepare()
|
||||
|
||||
mock_get_backend.assert_called_once_with(device, compute_type_explicit=False)
|
||||
backend.ensure_model_available.assert_called_once_with(model, compute_type, None)
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_explicit_values_override_device_defaults(mock_get_backend):
|
||||
backend = _make_run_backend()
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
Transcriber(ExecutionRequest(device="cuda", model="large-v3", compute_type="int8")).prepare()
|
||||
|
||||
mock_get_backend.assert_called_once_with("cuda", compute_type_explicit=True)
|
||||
backend.ensure_model_available.assert_called_once_with("large-v3", "int8", None)
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_engine_comes_from_adapter(mock_get_backend):
|
||||
backend = _make_run_backend()
|
||||
backend.engine = "openvino"
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
info = Transcriber(ExecutionRequest(device="openvino-cpu", model="medium")).prepare()
|
||||
|
||||
assert info.engine == "openvino"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_diagnostics_failure_does_not_fail_the_run(mock_get_backend):
|
||||
"""Сбой сбора runtime_info не роняет распознавание, а попадает в сведения."""
|
||||
backend = _make_run_backend()
|
||||
backend.runtime_info.side_effect = RuntimeError("plugin enumeration failed")
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
info = Transcriber(ExecutionRequest(device="cpu", model="tiny")).prepare()
|
||||
|
||||
assert "plugin enumeration failed" in info.runtime["diagnostics"]
|
||||
+5
-5
@@ -66,17 +66,17 @@ def test_detect_device_explicit_passthrough():
|
||||
assert detect_device("openvino-cpu") == "openvino-cpu"
|
||||
|
||||
|
||||
def test_detect_device_auto_cuda_when_nvidia_smi_available():
|
||||
"""При доступном nvidia-smi auto выбирает CUDA."""
|
||||
def test_detect_device_auto_onnx_when_nvidia_smi_available():
|
||||
"""При доступном nvidia-smi auto остаётся на ONNX CPU."""
|
||||
with patch(
|
||||
"local_transcriber.utils.shutil.which", return_value="/usr/bin/nvidia-smi"
|
||||
"shutil.which", return_value="/usr/bin/nvidia-smi"
|
||||
):
|
||||
assert detect_device("auto") == "cuda"
|
||||
assert detect_device("auto") == "onnx"
|
||||
|
||||
|
||||
def test_detect_device_auto_onnx_without_cuda():
|
||||
"""Без CUDA auto выбирает ONNX CPU."""
|
||||
with patch("local_transcriber.utils.shutil.which", return_value=None):
|
||||
with patch("shutil.which", return_value=None):
|
||||
assert detect_device("auto") == "onnx"
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.13"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.14'",
|
||||
"python_full_version < '3.14'",
|
||||
]
|
||||
|
||||
[manifest]
|
||||
|
||||
[[manifest.dependency-metadata]]
|
||||
name = "sherpa-onnx"
|
||||
version = "1.13.5"
|
||||
requires-dist = ["sherpa-onnx-core==1.13.5"]
|
||||
version = "1.13.6"
|
||||
requires-dist = ["sherpa-onnx-core==1.13.6"]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
@@ -260,14 +264,33 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kaldi-native-fbank"
|
||||
version = "1.22.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3a/2c/84076b352107ce12d56f28c313f1aca1be332d953dd96aec7b84976e6d53/kaldi-native-fbank-1.22.3.tar.gz", hash = "sha256:387bf87225c6b83c93ae652eeaef1b4d531994b6e398e7a77189de340674f9af", size = 71013, upload-time = "2025-10-09T02:31:21.487Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/df/4110f685067946c8b2e59ed76cebdf51c979ae999d90f65208a9d1966cba/kaldi_native_fbank-1.22.3-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:78ca163686a4aa1693194d098aa79b517845d851aa6fd27d5b162c05e1012361", size = 249056, upload-time = "2025-10-09T02:28:02.202Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/74/ef21aabdd2f32539735e2ed4d3ea072112d4e3d30dfc2d17695f6d9df072/kaldi_native_fbank-1.22.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3768ea99933aa25080cb820f93f7b612968633b9a4fa23bc8a7337e2137f3fbb", size = 229011, upload-time = "2025-10-09T02:31:50.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/72/adb11d27c545aca1db442da744ee430a6aae377a33574bfd2ec159dcf673/kaldi_native_fbank-1.22.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f74b85948328ab4b4c88522f98a59f83dd5295443b08483e945c7de2c35e5dcc", size = 299276, upload-time = "2025-10-09T02:30:38.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/1e/496c7ae814b2a7f8f47d423dc33aae2cdfb1edf898e2faaf5c5b39b90363/kaldi_native_fbank-1.22.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e3f9c6551ff5b6ae785dd15f819c3b2b7432d77bfb79ea8806748e2c7d900b5d", size = 322714, upload-time = "2025-10-09T02:30:32.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/47/3fcb52e0ef081efa40b4ca5c04f207509d31157f33f3ac314578d93794f9/kaldi_native_fbank-1.22.3-cp313-cp313-win32.whl", hash = "sha256:a63d5bd6b5bd5f7c0e0af886c12c3f686fbc62347f6b886fed2694ab2f0dbd14", size = 273293, upload-time = "2025-10-09T02:30:13.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/48/20bfa3f8d88605e2ec2c274c343dec1f112077e687440d64d3caa4b9136c/kaldi_native_fbank-1.22.3-cp313-cp313-win_amd64.whl", hash = "sha256:4fb769337c7d482166ada8ba041003e4a9de3a778dc970b6b5802a382e581724", size = 308032, upload-time = "2025-10-09T02:29:03.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/7e/d47f64d5332b2527e6b65490888d99793eb3280bca735d0b69348eaeb6a3/kaldi_native_fbank-1.22.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2efa8368cdd46a32c37a28c4baaa508b0a294ab1ca2aefddd3e97f62cfebc27b", size = 249216, upload-time = "2025-10-09T02:28:22.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/9f/f98f72ba5a90a39675e82f2175dc5ec99a85892a88b9ccdd25f2dc916c82/kaldi_native_fbank-1.22.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f6086073ec658a23d22f8657b3ee8c6ba69d65be57324a7284209ac7424b5ac", size = 229289, upload-time = "2025-10-09T02:31:17.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/4b/1f3f17a7b601124df88112a1d1fcb543c8d908d6674f752f7d3322991770/kaldi_native_fbank-1.22.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:41fb506fde155d97aeef95dd6ceccc38c2c5dd4401f9b8fded9bacaf1bafef36", size = 300037, upload-time = "2025-10-09T02:30:10.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/6a/374ec4e1cf13e672f5acd8272116c1885c2a7f84be491fc652415fc6e870/kaldi_native_fbank-1.22.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f1cc2b8eeec52a33868cf59bb95d40b335fa9cff7e15a6208e0e9b67b7fd7236", size = 322854, upload-time = "2025-10-09T02:31:26.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/2a/edd85a2292d2af28af68214a3bdd029ab8ce2e6bc5aaac77255aa57ce964/kaldi_native_fbank-1.22.3-cp314-cp314-win32.whl", hash = "sha256:d6387ab52b56e2978524590e11b24cf03419d9e9361965bc8d6ff34ff9e867da", size = 279733, upload-time = "2025-10-09T02:29:41.855Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "local-transcriber"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "ctranslate2" },
|
||||
{ name = "faster-whisper" },
|
||||
{ name = "httpx" },
|
||||
{ name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
{ name = "onnx-asr", extra = ["cpu", "hub"] },
|
||||
{ name = "onnxruntime" },
|
||||
{ name = "openvino-genai", marker = "(platform_machine == 'AMD64' and sys_platform != 'darwin') or (platform_machine == 'x86_64' and sys_platform != 'darwin')" },
|
||||
@@ -277,27 +300,44 @@ dependencies = [
|
||||
{ name = "typer" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
cuda = [
|
||||
{ name = "nvidia-cublas-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'x86_64' and sys_platform == 'win32')" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "kaldi-native-fbank" },
|
||||
{ name = "numpy" },
|
||||
{ name = "onnx" },
|
||||
{ name = "psutil" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "ctranslate2", specifier = "==4.8.1" },
|
||||
{ name = "faster-whisper", specifier = ">=1.2.1,<2" },
|
||||
{ name = "httpx", specifier = ">=0.28,<1" },
|
||||
{ name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=12.4,<13" },
|
||||
{ name = "nvidia-cublas-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'cuda') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'cuda') or (platform_machine == 'x86_64' and sys_platform == 'win32' and extra == 'cuda')", specifier = "==12.9.1.4" },
|
||||
{ name = "onnx-asr", extras = ["cpu", "hub"], specifier = ">=0.12,<0.13" },
|
||||
{ name = "onnxruntime", specifier = ">=1.28,<2" },
|
||||
{ name = "openvino-genai", marker = "(platform_machine == 'AMD64' and sys_platform != 'darwin') or (platform_machine == 'x86_64' and sys_platform != 'darwin')", specifier = ">=2026.3.0.0,<2026.4" },
|
||||
{ name = "rich", specifier = ">=14.3.3,<15" },
|
||||
{ name = "sherpa-onnx", specifier = "==1.13.5" },
|
||||
{ name = "sherpa-onnx", specifier = "==1.13.6" },
|
||||
{ name = "socksio", specifier = ">=1.0.0,<2" },
|
||||
{ name = "typer", specifier = ">=0.24.1,<1" },
|
||||
]
|
||||
provides-extras = ["cuda"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "pytest" }]
|
||||
dev = [
|
||||
{ name = "kaldi-native-fbank", specifier = ">=1.22,<2" },
|
||||
{ name = "numpy", specifier = "==2.4.3" },
|
||||
{ name = "onnx", specifier = ">=1.18,<2" },
|
||||
{ name = "psutil", specifier = ">=7,<8" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
@@ -320,6 +360,42 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ml-dtypes"
|
||||
version = "0.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/12/72/307d7c4bd0600601c7133fba5cb78af7db968152951c1cd473abb1cda782/ml_dtypes-0.6.0.tar.gz", hash = "sha256:5e60251d32ced5598972e4d5e06a2f044341f9291402551a3f6f0ec44f9299b0", size = 3032327, upload-time = "2026-08-13T14:14:40.215Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/50/51/fd1582b8f5ed8a9e7be0e161a6ea0dff70cb280479a12178df0b3a72700e/ml_dtypes-0.6.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:084dfe51a7ad58b171f05115f8226ed4233a454a1611371947e806e76f0c638d", size = 565468, upload-time = "2026-08-13T14:14:08.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/22/20fd70ca6ed12446cb92d5b2a7745bd185f9d8b8cdeeadad976574398e6b/ml_dtypes-0.6.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28d676428b104bb9717b0928bc5c5129f2d6b51b6727587cc4289e7bf8713cb5", size = 360232, upload-time = "2026-08-13T14:14:09.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/a5/da8ae6c6f1babe4b68e3e55d43d39b529e29774f10e0910671a6b8c86eb8/ml_dtypes-0.6.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26b1f1fa4f0435a2946859823f6e2bf06796f1e9f10f5a05b08a5e3c8f46ff69", size = 410169, upload-time = "2026-08-13T14:14:11.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/55/4561acefa00fa4bcbfb82ca6a48578b41f372cd7dd7cdd6eb4720abc2e5f/ml_dtypes-0.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:fb87f46b4f7ad7b5d3ad8f4b452b024bd4229d44c8ff934798c1fe656210387a", size = 439357, upload-time = "2026-08-13T14:14:12.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5d/6a01538e507ef0ed5e879985b13a92467bf8960696fb1131f8b8cadc60ff/ml_dtypes-0.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:57ed0d6b4ac5e7868361303a9c57fbcf63b768236ee14456f585dfcf260d0292", size = 552278, upload-time = "2026-08-13T14:14:13.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/7a/97dc35667b7c9db33c5344c673cd27f87e34771875ea7100138726132ac9/ml_dtypes-0.6.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:84fa136b8602c8c39e3b6cb24918960cd6f36cade7a70376f56770729cd56510", size = 562551, upload-time = "2026-08-13T14:14:14.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/48/77f0ede10558d0d935da2e3276ed7e9c8cc2bad3463b9a0b66b03fc60be2/ml_dtypes-0.6.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:317be9967fb84b0ce4e80e6b1bf71213d21971621cf6f1e501a63602a95297bf", size = 360334, upload-time = "2026-08-13T14:14:16.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/b1/1831dd8c9b06c013085d31a2ac4f03392d43bd36bfc6ff591a08bcedc1cf/ml_dtypes-0.6.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f490c003369ce60e514a0c3b12374f05274c101fee1bead6740ec8a564032b0", size = 409966, upload-time = "2026-08-13T14:14:17.477Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/ad/9c32c53f823dda3742df19a79c10bc198365937873ea125ba65747440c23/ml_dtypes-0.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:d574c2b28921dc72e869df248f1a278f6eee176a1f237c8642e1a71eb15f3977", size = 457224, upload-time = "2026-08-13T14:14:18.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/3d/dd98205418a13353d41c52bf5326d8cbec515aace46174e23c6ea01c2978/ml_dtypes-0.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:f4adb4af61516510d786cf8c01851a66f6d3ddfa79e1144deaa5b40d8507231e", size = 568378, upload-time = "2026-08-13T14:14:19.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/36/32e7beef3281fed74883451477ad976364323206dbfaa95e948ba788dac7/ml_dtypes-0.6.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:3e169214e0d80ff1c038e1b3017e33c23e43bdf948d42d31de8283111c7e2fa3", size = 590177, upload-time = "2026-08-13T14:14:20.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/a2/99b3d9b3c984b3bd1e81d8244f1fa2f812e44060d853205b2df6271aa17c/ml_dtypes-0.6.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:573b11f3c327e17ef3826d266e676cf1149a1f3016f822a05f2306c55d8246bf", size = 363142, upload-time = "2026-08-13T14:14:22.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/fb/8091c0aee7f2712de99c7fd4b1642382644dec6a4962effe4f5b9d16a973/ml_dtypes-0.6.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b76fa1d3f92967d58289ac47ab7458ede66e6f3527fff3e59142aee57d9307cd", size = 430645, upload-time = "2026-08-13T14:14:23.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/6f/962d2c589513b5930d05b6eae5fbd22ad8bbcf26bb763449f3d8f912360f/ml_dtypes-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3be9911d953f97cddded4b9961d7b650473b7e55806d20f6176f8356dfe7b38e", size = 465667, upload-time = "2026-08-13T14:14:25.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/ca/bcb25e246edd19af5fa1cf6267040bd9977a7afca846e6cfd4a52078b44f/ml_dtypes-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e74266ca8e97874a937b7646378c178025650a236584f7474d10d8086a6edea3", size = 572706, upload-time = "2026-08-13T14:14:26.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/42/46cb442648e3c774d8cb25f2e1e41d496cdcc91fbe9c2a6f75c0b8df7af6/ml_dtypes-0.6.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:b1b503864fada3f74fabf8d9fee7b4c1cbe956301e6fdece975d5f77c2fce958", size = 562550, upload-time = "2026-08-13T14:14:27.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/56/844eff5af7a2d1a09d75df12c70225c3a6b6a771f95876b2bf5f7d10ad44/ml_dtypes-0.6.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c6ad60af4102789a5c09824004beade2f7f28cd1cd581ee5c170d9dc2fbb00e", size = 360332, upload-time = "2026-08-13T14:14:28.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/29/b7165a3a76364a5baa6aa4ee82a0adf73a3c014b8cd126120b62cc087992/ml_dtypes-0.6.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4f1b9329a251e4affe3bb58f4d3e2db22a714396fd7ffb40d0b5db423c24d17", size = 409964, upload-time = "2026-08-13T14:14:30.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/2e/f61c54a0544b6a170ac1bb89bcf406af53fb2deffc5476b6d2d3df5ba13e/ml_dtypes-0.6.0-cp315-cp315-win_amd64.whl", hash = "sha256:488c99ab181a2f59d9ec3b12c5fa11ec904e92be2c4ba18cded54dd7501208fe", size = 457249, upload-time = "2026-08-13T14:14:31.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/00/bee1bc9faa02a46e7a851019fd23f47ca1f906609edbec8b6ba5decc3cc3/ml_dtypes-0.6.0-cp315-cp315-win_arm64.whl", hash = "sha256:de9d14748dbf3968951436ef514a29c9d1fe438aa680d110134ee2f7a9f9df18", size = 568381, upload-time = "2026-08-13T14:14:32.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/f7/9a5edede28f73185fd51d75030ef7f11d76997bab3a92427d986e54fe2eb/ml_dtypes-0.6.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:e25bb3b0ad1217b60626e4ed45b10ca170c41d99fbe44a12bebc1e07ec4aad55", size = 589877, upload-time = "2026-08-13T14:14:33.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/81/d5924a141b850b606eb027493c9c3ca3c665cca5163af3f5b6e5e3345503/ml_dtypes-0.6.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31f1ce979d31a357e95aa81812f20412c8c954fa43c44ee3ead1e1c8a78575ef", size = 362788, upload-time = "2026-08-13T14:14:34.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/8f/3298e3f334832bc28dd144af6b99cdc93502a8687e71922ea68b0a319929/ml_dtypes-0.6.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2d6149f3a57f405bcad5fb41e03218b8373936253f23e1ca84c0108abbc3392", size = 430823, upload-time = "2026-08-13T14:14:36.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/d2/f2dbf118f42ce4c325a139c9236737f436b7f8e00cd18701c99ef2405e6f/ml_dtypes-0.6.0-cp315-cp315t-win_amd64.whl", hash = "sha256:ce7563e0b1a4482cbc1b4a6272145e54e4489e54fe7428f94908c3d87103abfa", size = 465119, upload-time = "2026-08-13T14:14:37.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/ff/bda40387b5c5c64254595f4d81a12351770856acc5de4e6d43606a31f161/ml_dtypes-0.6.0-cp315-cp315t-win_arm64.whl", hash = "sha256:f6cb525101b6b903779188c1e9e9490c343b455ab822883e02cf01e5547338d2", size = 572666, upload-time = "2026-08-13T14:14:38.993Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "2.4.3"
|
||||
@@ -376,6 +452,33 @@ version = "12.9.1.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/77/3c/aa88abe01f3be3d1f8f787d1d33dc83e76fec05945f9a28fbb41cfb99cd5/nvidia_cublas_cu12-12.9.1.4-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:453611eb21a7c1f2c2156ed9f3a45b691deda0440ec550860290dc901af5b4c2", size = 581242350, upload-time = "2025-06-05T20:04:51.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/a1/a17fade6567c57452cfc8f967a40d1035bb9301db52f27808167fbb2be2f/nvidia_cublas_cu12-12.9.1.4-py3-none-win_amd64.whl", hash = "sha256:1e5fee10662e6e52bd71dec533fbbd4971bb70a5f24f3bc3793e5c2e9dc640bf", size = 553153899, upload-time = "2025-06-05T20:13:35.556Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "onnx"
|
||||
version = "1.22.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "ml-dtypes" },
|
||||
{ name = "numpy" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/19/8ea73a64b368b75fe339771a20a02bc61ea1f551484c9e3d9d0bfbd0450f/onnx-1.22.0.tar.gz", hash = "sha256:ef40c0aaf0b643857ea9306fc7eddce17eaf9fb0407e4801f1fc5758443a38e0", size = 12024721, upload-time = "2026-06-15T12:50:05.354Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/6a/481561f1093834376ed493e4ca42a73e5be0d50031f2969c86593bdc7c96/onnx-1.22.0-cp312-abi3-macosx_12_0_universal2.whl", hash = "sha256:596fbf0490947533c1c1045ba860851dc9fb77471023dac9a71ba5b42ceab103", size = 20167081, upload-time = "2026-06-15T12:49:32.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/55/b34fc2aa30aa54b4a775402d24c4082242c720283a274fe976ac8eb94480/onnx-1.22.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae5a563f281cd9d2845622cecf6c092a57e4ee1b138f66fdbbdd4200567a5e16", size = 18889249, upload-time = "2026-06-15T12:49:34.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/a6/bd32357e6cc1ecb473afd78193d7231724f284435d2db25696ecfaaa1503/onnx-1.22.0-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:955e02e1f6d385b53d52f9cd7b9cdf5caf417c300bcfe3c64c6d542be763845b", size = 19106514, upload-time = "2026-06-15T12:49:37.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/9d/3af461ac6c714b8b369cb71499659932f4f12cfb066250b62f7567c3d530/onnx-1.22.0-cp312-abi3-pyemscripten_2025_0_wasm32.whl", hash = "sha256:82e9f27fc1223cb06d68a56bed6f9d3caf3d0dad1b61bce45006d529b15bd94c", size = 16966387, upload-time = "2026-06-15T12:49:40.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/f0/68195b5e5a53e333faf2660f5352ee43738d0e42fc5216cc6b1871a9fbfb/onnx-1.22.0-cp312-abi3-win32.whl", hash = "sha256:cc8b66b312f8f03a53e268afb67180a2d97dd12cc79e2b61361c6c0073448016", size = 17081568, upload-time = "2026-06-15T12:49:43.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/a8/734725bb703c5fabb687f79c79e51249475212b3eb37771ac4a4ac9b487f/onnx-1.22.0-cp312-abi3-win_amd64.whl", hash = "sha256:72ccebab3bac07215c204ce8848d42e78eaaa666badbf72d25cd359b9f269e3a", size = 17213290, upload-time = "2026-06-15T12:49:45.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/2a/8ce48d8ae26a8761ad4e5dc771961b155c5c3c7c8540ec7f2f2d71b69af0/onnx-1.22.0-cp312-abi3-win_arm64.whl", hash = "sha256:f3c120dcdb70ad738f3c061b32798f408ea299eb69f84dd69ab4a6bf3c2ec01f", size = 17207030, upload-time = "2026-06-15T12:49:48.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/13/47323b97846387848efb1044ded11bb94b83526f3d1fbdb37c6480d4520f/onnx-1.22.0-cp314-cp314t-macosx_12_0_universal2.whl", hash = "sha256:19e45e4af88e3fe3261458d4b8cc461957ae2782a358a3560503569bf3b23b72", size = 20176465, upload-time = "2026-06-15T12:49:51.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/0c/d3b8a7e7eee123938586c608bb9894b5723f2342b9450c0eec59fbec7099/onnx-1.22.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c21a0e59fd967a95b358e4a6e756d1f1eec2d304a83480f329f66e30d2bf0223", size = 18894028, upload-time = "2026-06-15T12:49:54.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/8a/da2a97ab46fe6e0cd9beb3ac14603a22f5be492f9ca347faf8233a07bb33/onnx-1.22.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2632406b8f523ef2e2873c363f90b20a3d88c0fbcfac757d3addffccf8f452c2", size = 19110420, upload-time = "2026-06-15T12:49:57.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/a3/ce984063017518307ebfaa545782fc400e593dc2d7fdf4f23ce4be1ed197/onnx-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a3a39fc4643867aecb33417fdddb11e308ee79d2d4a584b9d50cc7aec2091b13", size = 17237547, upload-time = "2026-06-15T12:50:00.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/50/257a880384a1dd502d543b0067945074d63cd17d0840e958355bc8197da8/onnx-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:8e268cdc0547e3949799ffd4a44451dc2b9080b57d0824a2db680b6ec65506f0", size = 17231391, upload-time = "2026-06-15T12:50:03.047Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -512,6 +615,34 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/e7/14dc9366696dcb53a413449881743426ed289d687bcf3d5aee4726c32ebb/protobuf-7.34.0-py3-none-any.whl", hash = "sha256:e3b914dd77fa33fa06ab2baa97937746ab25695f389869afdf03e81f34e45dc7", size = 170716, upload-time = "2026-02-27T00:30:23.994Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psutil"
|
||||
version = "7.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
@@ -606,47 +737,47 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "sherpa-onnx"
|
||||
version = "1.13.5"
|
||||
version = "1.13.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "sherpa-onnx-core" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/a1/8a7d8751bc71a0814f3b9332717909512a5ff919d07ed53e4baa861ef8c7/sherpa_onnx-1.13.5.tar.gz", hash = "sha256:14bebfe71365a2c678dd94cd08efa8e79df06318b17fe8e97b2e802a7881fd5a", size = 1037285, upload-time = "2026-08-11T08:17:31.817Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a6/12/ca1900b2ad6308f76b9f744367748ce81adab2fb70a5665acf0a6f3c34f0/sherpa_onnx-1.13.6.tar.gz", hash = "sha256:d55a0e769efd9b014c2d3aaa54754288dc39c7261e9334c575d54ba22214ca27", size = 1037965, upload-time = "2026-08-18T06:55:13.107Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/6f/b70ae4c7e367e3d2dca99efc65152d22ad5d942619add19172d24267c14b/sherpa_onnx-1.13.5-cp313-cp313-linux_armv7l.whl", hash = "sha256:15a02d9d74143f336156cb8b4da826bc65c11daa28bd5c9099e9397b3660e7df", size = 11999210, upload-time = "2026-08-11T09:26:07.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/ec/d471d042cc85c505515e3fbaed55223f54718a9ac3a8f228a8e1b81dab6d/sherpa_onnx-1.13.5-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:371b0eb51caa12f5f3e9c327c440a4aee6f77c163c89666469844eaefb7d5351", size = 4439247, upload-time = "2026-08-11T08:30:33.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/58/2f60eb29db5f41c01ad844be34ed1adfdecce9493b12cc20bbdc6b6ad5a8/sherpa_onnx-1.13.5-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:2bdfefa5d16896c90d1685bde43682818e70b95e134dccc95b91dc0819adde1c", size = 2340028, upload-time = "2026-08-11T07:18:47.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/71/25f067216edcde5e78cf0d9a7ac053a2b3b17bdf131acac69447daa23356/sherpa_onnx-1.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:05a3effd8ea5ee78735bc28909c0fce26d9f23bf61db663e6f75f04ec0247ba4", size = 2137522, upload-time = "2026-08-11T07:43:35.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/77/b36cd40906bef1b3a6593200587eea5c4e7c1e94d29c4fd88a876e1cd6ca/sherpa_onnx-1.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5a6cc5ac96043670faa0f5c0e56310315a4600cf7b764fee014e7dd75fda00f", size = 4167203, upload-time = "2026-08-11T07:48:34.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3b/817cdb5770f8beafd22a04a8eda0c8253a6d07a518a09ce982d6e4152e3c/sherpa_onnx-1.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb7c1f5ca5455b3ec008f3e09438bca6038d22b4fe5d64adb5c09bb6b84eeb82", size = 4392107, upload-time = "2026-08-11T08:03:49.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/75/b8494e30923a18db144e0035851f0364c32e63d776b62f3d2667f717f499/sherpa_onnx-1.13.5-cp313-cp313-win32.whl", hash = "sha256:80d9ec3ce2fe8b566285ef92fa2e72b4fa0aee10f73290ac74219fe415806980", size = 1961338, upload-time = "2026-08-11T08:35:46.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/28/e8644784a897e4ba6cdc7a635d821107ce31567c90e1835a0c008ba2a322/sherpa_onnx-1.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:440174433b5d3f855a800d757e66812e998ec9715f5f42690afb27a6ff456586", size = 2279915, upload-time = "2026-08-11T08:51:50.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/99/1aa369391f62a2753b5d97b0b9ecd99475410c51eada10f7079628a58c4f/sherpa_onnx-1.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:38de7f31754dfa7212beb4cc773ba05f78d028adb4c0e008920128388657f778", size = 2239783, upload-time = "2026-08-11T07:46:44.679Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/a3/ac238819cca1955b6b80ae802671cc7e4d688c673fb98001c21111922045/sherpa_onnx-1.13.5-cp314-cp314-linux_armv7l.whl", hash = "sha256:a224466e750a435089311d4c763617a4d83478a2f958c311b473ea59f9cce3ae", size = 11997852, upload-time = "2026-08-11T08:54:11.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/82/3f5b50f84b9a8571bdfe9939983a8e6dff75110961305315887bbc83f626/sherpa_onnx-1.13.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:1a4ea5fa45caf31093ff832ab4d198e097edbeeb2244a0d4f958e96bf888110a", size = 4442303, upload-time = "2026-08-11T07:36:28.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/90/6d3a1112bc4496b0b918d3a807036912f1f34fb4a4dc506f0cabf398c4a9/sherpa_onnx-1.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7582ca42ed2d80bacc984b46904cbdd058ec2b6149a8c8996d7263217a4cc963", size = 2340527, upload-time = "2026-08-11T07:54:35.482Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/9d/0cb152e3fd1aa5787ade99c4863cd8cf80d800b879e8ff47f8f99412af78/sherpa_onnx-1.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97471fe025fc1d655a1df2f4ffb5d3fe843c269f2016d5e1cca4b0d168a49169", size = 2140247, upload-time = "2026-08-11T08:23:33.405Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/84/38531f16e2c2a0b077257b25c34c02a1c8f7f1f833d5bb61e2b2f510e260/sherpa_onnx-1.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2a51751f6c925c405ef6ebcb2f7c44794810da79d0f2743fe57ee3daa5bc4df5", size = 4172385, upload-time = "2026-08-11T08:00:34.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/91/56a439945713a5cf49080a833fbd0953936fcd50c651c17afe64478c9922/sherpa_onnx-1.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:eb4d580d959e16c42dbd404cef0f5764ed1c024edf48d3edc43b5e2f1c96f069", size = 4393503, upload-time = "2026-08-11T07:38:11.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/27/cbdfa8bdcf618b28ec5cf383b073be851ff3178ea06d6f1a0fcb3c7532e8/sherpa_onnx-1.13.5-cp314-cp314-win32.whl", hash = "sha256:64cb492cf8a9751f632685c6f169fef7e5a2f449efb9923bc21b6e783a911dba", size = 2000807, upload-time = "2026-08-11T08:57:11.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/c2/162840897a528a528bc0f76cf3516d4e3ac1445db9089caf7733df756cfc/sherpa_onnx-1.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:001e1c3440fe335a589c9283a68105f379ffb63f2d1d7c2878e99a5d72eaf5af", size = 2345302, upload-time = "2026-08-11T08:11:46.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/5e/9a9c63e1dcd646c5f21df6d16e6770fedd956f8a54241f3382343c6289af/sherpa_onnx-1.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:a7e97d8457a51725ffa3fcb44cb1d57bfdb780bc390a9460a65e05bf3d2851b4", size = 2303336, upload-time = "2026-08-11T07:33:25.917Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/52/5ad50062b757b0d99b297594b106173bdcc50480f512dd5ae97cd0d2bc09/sherpa_onnx-1.13.6-cp313-cp313-linux_armv7l.whl", hash = "sha256:788aef27900e15d1bf6482c6f745a26f6ded9b41aaa1fddbacabf6a20366e523", size = 11999760, upload-time = "2026-08-18T08:47:38.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/2a/31a94c922813471ef8284658d4dfeae0ceb1d94de08cd8c6728aa95f4e79/sherpa_onnx-1.13.6-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:4438843292ddcff7267e79c032b4711d3f54529434a5e225c820cc0da4c489a4", size = 4439567, upload-time = "2026-08-18T07:20:58.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/47/1d1e726b4f15bd66cbe0b73bf81334764d6025e05738d984ebdd96a78e0e/sherpa_onnx-1.13.6-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:0c357e37fbb958edd93841ba40723b2447d5470fc683329b8eadd32d454320c3", size = 2340457, upload-time = "2026-08-18T06:39:53.405Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/04/552c85d7f0b6ba1a1008da0568054d5c834c644f26057a24baf92dcdf876/sherpa_onnx-1.13.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdb0f9fcaae89f8acd1ee9ce5fe89e77be3aa240cdedab3f48aa7b19800d8b72", size = 2137534, upload-time = "2026-08-18T07:59:20.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/f3/5d50f5e675d972c39d908f82b554e6e1734c2d165e6573b8b92e7b0f3aaa/sherpa_onnx-1.13.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:248e3d039dba42ffeb141e7cb914f297e23199fca94e0413259dcdf5fb281960", size = 4168068, upload-time = "2026-08-18T06:28:33.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/3e/c3a60cf13301363210e7a0cc72a901ae6a56e313e1d912df0017e24e6e79/sherpa_onnx-1.13.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5212391170d5ac2fdd02109b313cc1120393ededc2a06dd9eed731a4ca24f068", size = 4391526, upload-time = "2026-08-18T06:41:25.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/fe/26d6a5aaae30f79e987ca55e482f6a92839e579e1eb0b6dc2987e1390b5d/sherpa_onnx-1.13.6-cp313-cp313-win32.whl", hash = "sha256:1743cf209361647430d83f44cd2ddd686f74a9a8442699427de4223ed1b17066", size = 1961677, upload-time = "2026-08-18T07:15:46.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/04/a5cd9dd2a6e5073ca5fb8df28a95ecb49df71a8b3ff822aa57f294dec985/sherpa_onnx-1.13.6-cp313-cp313-win_amd64.whl", hash = "sha256:3387c754668578612aaf7795f894b631faefb71fd7d7df20c2aa41aa2dbc59a2", size = 2280022, upload-time = "2026-08-18T07:52:46.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/a4/0e0194e561c6670315a0efc09ec80680ae43d196572974abba624ad9ac90/sherpa_onnx-1.13.6-cp313-cp313-win_arm64.whl", hash = "sha256:b52143c11ff064a02b80c231ce462eb8e258fc5a7b994c750ee8145ca79c114d", size = 2240745, upload-time = "2026-08-18T07:35:12.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/a3/62a284cba29e3c9307f24794a65167371cca1390c3b690458f15fcbb75e3/sherpa_onnx-1.13.6-cp314-cp314-linux_armv7l.whl", hash = "sha256:a7c05186d279db4e1ef8949d798c88a05b524f95743966667b70f70ea18243e2", size = 11997843, upload-time = "2026-08-18T08:41:04.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/7a/fd4a1b1e1580cbb6623fbb06f1b05afac69a4d84867541f4e653a422d8d8/sherpa_onnx-1.13.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:3105aec59b691ba59c75148261c2d7a64614fbeb7470b8116c04e8ec01aa9514", size = 4442856, upload-time = "2026-08-18T08:02:22.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/de/ba9022fb7d5b7e7818bb456d10220702468c8be0ecfe3d164347b165439b/sherpa_onnx-1.13.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:afd08d1337d14d8f27f70802096c237044b3fc9c922d8b40d7f8b12d84566a10", size = 2341119, upload-time = "2026-08-18T07:29:01.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/32/8889ae5e10eaf1c29d0e9f7ff63ad9b5168dd7304274666d23c4a48aa841/sherpa_onnx-1.13.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:89ca7cd437bf250be60d297ffdb58b77acc5c6beef2d90e84029a0cfaa8783fe", size = 2140675, upload-time = "2026-08-18T08:10:34.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/a0/05b594030df8c52e9a89e1615e3b6d59f99db1c884a33f6d5d2782045650/sherpa_onnx-1.13.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:370cc39be2613f27640e9fd6e326556109e2202d11d1420c8ba1616fc4eb1b90", size = 4173222, upload-time = "2026-08-18T06:45:53.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/92/638cb914a924d9039270113c8ecdd419a82a56072141bea85d8a88433ade/sherpa_onnx-1.13.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:243e8dfb4749b4ded8893f9e6af90615a16c5df1ad7c8aa211f5207911cfe9b2", size = 4393166, upload-time = "2026-08-18T07:08:41.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/12/389b69bb78339d498ffee9f2ce40e8c27d55f46f45606aaafa23c9af586f/sherpa_onnx-1.13.6-cp314-cp314-win32.whl", hash = "sha256:9b0bffb23a428139983c4f42753b924097b0181a74257e2e529bd70f4fb49e9b", size = 2001391, upload-time = "2026-08-18T07:24:22.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/7c/f7f4d89088114a8b861c8b5416f14c67ca3bdae652ea2e9499f438c17dc8/sherpa_onnx-1.13.6-cp314-cp314-win_amd64.whl", hash = "sha256:aa4321c92ec8b12f602664551c72cbe0c920f5002009dc9457d6e092639b0ae2", size = 2345347, upload-time = "2026-08-18T07:51:40.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/0d/caf428b986911a9027e9e30d2735456557be3e7ea86ab70b8cd2b7fc7514/sherpa_onnx-1.13.6-cp314-cp314-win_arm64.whl", hash = "sha256:6b5861db46d126a6b97b05432e4122ba2b8505f17f2ee16c1b377c9f3251ba36", size = 2303436, upload-time = "2026-08-18T07:33:28.755Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sherpa-onnx-core"
|
||||
version = "1.13.5"
|
||||
version = "1.13.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/fe/2a972bc05bc1c674275a97d16d387545c6efa811bf2a8dc1b6ac573f9fe1/sherpa_onnx_core-1.13.5-py3-none-macosx_10_15_universal2.whl", hash = "sha256:a5380f4f8ec609e1c68f2052e34a55862098fa0efa0bba0af33adbdc0231fc4c", size = 19850412, upload-time = "2026-08-11T07:15:17.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/cd/5730372ce10ba08fda03a3e3dad4ec8bc2a62607530a8f32c44e63258c51/sherpa_onnx_core-1.13.5-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:6bb5c460cd432293a290c861fbd519e30ac290ca84b5acd1c4447c22aef03351", size = 10614601, upload-time = "2026-08-11T07:00:58.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/54/b753e2dbd2b0f09aed2d2082f35c41606af9712457278ff99d13eaae92d3/sherpa_onnx_core-1.13.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:899e88916efd96ee1eabc512e9ceaf2fdb711b20b2512cf40358b623e90b5ded", size = 9282744, upload-time = "2026-08-11T07:09:47.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/58/505668cedd7036bcba305c1a1f5424a3340f4701297a628de83d61e8ed58/sherpa_onnx_core-1.13.5-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4cd751063a378a49f0c72eba5ba959fe375397f5baf93a53f3db64097d00e2aa", size = 13051193, upload-time = "2026-08-11T07:00:47.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/61/2cf3aee64c280c830ca4fa25584eb87f8acf04787152744a29d576323977/sherpa_onnx_core-1.13.5-py3-none-manylinux2014_x86_64.whl", hash = "sha256:485b20188ddf905ffbd7504ed456a05d0ff84d10c592a16c0adb7d69bb845f82", size = 10371666, upload-time = "2026-08-11T07:19:12.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/08/c04236fdf7825c4149b8b28b24d5a26f9e3fdca879aa24b8f1c65e96745b/sherpa_onnx_core-1.13.5-py3-none-manylinux_2_35_armv7l.whl", hash = "sha256:12697ff5745e225157b3257fe638e8b6305b1860366b6387e7a586ee6de2b18b", size = 9920766, upload-time = "2026-08-11T08:48:07.866Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/c0/6d19a5b4be1541e2bd17ec045d0c48a641055d290007826ddee3a081a905/sherpa_onnx_core-1.13.5-py3-none-win32.whl", hash = "sha256:6b2f19b7559ad278aa17af0b9f3917a90a37fef4501c04afb14e722f4848c9b7", size = 14534505, upload-time = "2026-08-11T08:22:22.047Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/67/61611b5b945d7ac860387283a352860df8ab0431e3cc0868c1d5291e9f86/sherpa_onnx_core-1.13.5-py3-none-win_amd64.whl", hash = "sha256:fb48f5b7b4596bb745e5dcbb04616d841712224cba652717a71a4551d8ee5b63", size = 16522273, upload-time = "2026-08-11T07:09:46.3Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/b2/d7ee13aba3fc40316e2363a5ac0780be7b0112050f7e8821bcd00d6af024/sherpa_onnx_core-1.13.5-py3-none-win_arm64.whl", hash = "sha256:7e24d3ddbcb1e6447467b214ff1235dced21fa4107de5c55125fb3c95ff10501", size = 15683505, upload-time = "2026-08-11T07:11:03.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/cd/633f4a3eb8841e9d849dd130d20498a9315be03d27f5050c2ca139a16226/sherpa_onnx_core-1.13.6-py3-none-macosx_10_15_universal2.whl", hash = "sha256:365539159406cd41ff5031e5db68d6e402a392a15b3a77b2e952f579c1945f3c", size = 19850670, upload-time = "2026-08-18T06:14:56.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/89/2ee5d5d79f3bea896d89caff95d641881b58ddd629d538801b15cef2747c/sherpa_onnx_core-1.13.6-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:f5a672a02d42d1c369ddd129f8be57bfe8b7e785140a13976ff7633e27c4ffdd", size = 10614866, upload-time = "2026-08-18T06:29:24.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/ee/dbbc6718e263e4961106e057c187875e00485ca8030a6bca0d9682807d30/sherpa_onnx_core-1.13.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2a413121dd0ee2e53c45def3ea4e8f0e968a6ac74f8cfd288bed3ae2cc815c33", size = 9282956, upload-time = "2026-08-18T07:21:24.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/ab/ab279cf4eb6d4aa4b5f703d4e8233365c3acee304b963a09d4cb71601324/sherpa_onnx_core-1.13.6-py3-none-manylinux2014_aarch64.whl", hash = "sha256:e247b203ca64ab7095864d3ef1a21159787e02a985a7df137652604377c7833e", size = 13051341, upload-time = "2026-08-18T06:14:08.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/0a/6f64568ebb230b200c30c465eb002113944705fa36c734838ea46f2e1802/sherpa_onnx_core-1.13.6-py3-none-manylinux2014_x86_64.whl", hash = "sha256:9c1260ae150ebd4f658854fd8d8ca8477828afa5bebf66c1b2f6132f1f704a21", size = 10371677, upload-time = "2026-08-18T06:15:23.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/ba/170e3de104c091e95e49d8f547473d58f925ec55add3729b49e3498efce4/sherpa_onnx_core-1.13.6-py3-none-manylinux_2_35_armv7l.whl", hash = "sha256:21e3e0c90cb9d3117d8ea64b3c3e598e6b85098b35582fed6ebb742625d33fc6", size = 9920659, upload-time = "2026-08-18T07:43:34.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/29/470348cf8a8e580f8db99f7f3a6f49bba4657ea0ad8de58f15c8e4be83f0/sherpa_onnx_core-1.13.6-py3-none-win32.whl", hash = "sha256:97103228dde9b772a53c200a5c1e0dc1821f09e3139e0a72431a95a28a4eb62a", size = 14534700, upload-time = "2026-08-18T06:31:06.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/61/d32923b9f6fa039271760e1c642ffd496b8e2b3340e084bd9b0c7b568448/sherpa_onnx_core-1.13.6-py3-none-win_amd64.whl", hash = "sha256:9e7ac522b4f108412f6a1173c0e512c65e98d4714d9d9aa0002279d588d2e699", size = 16522526, upload-time = "2026-08-18T06:51:39.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/af/025e55fc07fde16a7abe19f0d1616469e8e4b56424323f0721f321a064ab/sherpa_onnx_core-1.13.6-py3-none-win_arm64.whl", hash = "sha256:36ce28bbcab720627e53befa1adc4573ffb966d9c9ce7d78e260cd8a5edb9103", size = 15685909, upload-time = "2026-08-18T06:37:19.099Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in new issue
Block a user