Compare commits

...
66 Commits
Author SHA1 Message Date
Dmitry DementievandClaude Opus 5 9b40e2b4c1 docs(readme): команды установки снова ссылаются на GitHub
Зеркало на GitHub доступно и практичнее для пользователей, чем приватный Gitea.
Ссылки на задачи в ADR и бенчмарках остаются на Gitea — это трекер.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 10:19:07 +03:00
Dmitry DementievandClaude Opus 5 b315f60746 docs(agents): убрано упоминание GitHub-зеркала как remote
Зеркало поддерживается средствами Gitea и в клоне как remote не существует;
строка вводила агентов в заблуждение.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 10:13:13 +03:00
Dmitry DementievandClaude Opus 5 50787c6531 docs(research): архивировано исследование ускорения на AMD из ветки feature/amd-acceleration
Два документа от 23–24 марта 2026 (DirectML, whisper.cpp + Vulkan на Radeon 780M,
XDNA NPU и бенчмарк качества на Ryzen) перенесены в docs/research и docs/benchmarks
с пометкой «архив». Выводы не реализованы: ADR-003 выбрал OpenVINO GenAI, авто-выбор
устройства закреплён за ONNX CPU. Ветка после переноса удаляется.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 10:06:13 +03:00
ddmitry 328580abfb feat(transcriber): deep module выполнения транскрипции (#58)
Закрывает #56. Спецификация: docs/specs/2026-09-21-transcription-execution.md.
2026-09-22 09:46:22 +03:00
Dmitry DementievandClaude Opus 5 63307fed01 docs(adr): ADR-003 описывает engine adapter'а вместо удалённой таблицы
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:36:26 +03:00
Dmitry DementievandClaude Opus 5 6f1064ccc4 fix(transcriber): закрыты находки ревью по module выполнения
- неявный compute_type при fallback переразрешается по умолчанию CPU
  (float16 CUDA не переносится на CTranslate2 CPU);
- prepare() помечает module готовым только после проверки пословного
  контракта;
- движок сообщает сам adapter (атрибут engine) вместо параллельной таблицы;
- сбой runtime_info() не роняет запуск;
- каскад конфига оставляет model/compute_type пустыми для module,
  CLI не дублирует CLI > TOML;
- удалены недостижимые из CLI сообщения о mid-stream fallback;
- в --verbose потоки помечены как запрошенные, adapters сообщают
  применённые; OpenVINO прямо говорит, что --threads не применяется;
- тесты: умолчания по устройствам, TOML compute_type, не-strict сценарий
  обычной ошибки; CONTRIBUTING.md и AGENTS.md без устаревших имён.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:32:04 +03:00
Dmitry DementievandClaude Opus 5 f7c0ed9a8c fix(transcriber): пустой язык трактуется как автоопределение, как раньше
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:06:03 +03:00
Dmitry DementievandClaude Opus 5 a8241b1d6b docs(execution): описаны владение выполнением и диагностика
ADR-002 и ADR-003 отражают Transcriber как единственного владельца
загрузки и fallback; README получил раздел «Диагностика исполнения»
и уточнение --threads для ONNX; AGENTS.md — новые тестовые паттерны.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:05:27 +03:00
Dmitry DementievandClaude Opus 5 0f8ecb5bd3 refactor(cli): одиночный файл и батч переведены на module выполнения
CLI строит один ExecutionRequest, создаёт Transcriber после prescan
и получает результат с ExecutionInfo: разрешение auto, device-aware
умолчания, проверка пословного контракта, строка устройства для шапки
и fallback ушли за seam. --verbose печатает движок, потоки и runtime_info().
Удалены load_model, _transcribe_file, TranscribeFileResult,
apply_device_defaults и тесты переноса полей; сценарные тесты CLI идут
через CliRunner с fake adapter на get_backend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:05:27 +03:00
Dmitry DementievandClaude Opus 5 e22b2f1bae feat(onnx): бюджет потоков применяется к сессиям ASR и VAD
--threads больше не игнорируется ONNX adapter'ом: cpu_threads > 0 задаёт
intra_op_num_threads через SessionOptions обеим сессиям, 0 оставляет
настройки onnxruntime. Фактический бюджет виден в runtime_info().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:55:28 +03:00
Dmitry DementievandClaude Opus 5 4dea7f4bc6 feat(transcriber): добавлен module выполнения с владением моделью и исполнением
Transcriber принимает ExecutionRequest, разрешает auto и device-aware
умолчания, загружает модель один раз на запуск и держит fallback внутри.
Caller получает TranscribeResult и ExecutionInfo вместо model/backend/path.
Публичный transcribe() идёт через тот же module. Adapters отдают
runtime_info() для диагностики; legacy load_model/_transcribe_file пока
остаются для CLI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:46:54 +03:00
ddmitry 343a31b35b feat(cuda): добавлен необязательный runtime для Linux и Windows (#57)
Closes #2. Независимое ревью Opus: APPROVED; 524 теста прошли, 2 пропущены. Реальный Windows GPU-прогон без Toolkit остаётся невыполненным и зафиксирован в документации.
2026-09-21 23:31:29 +03:00
Dmitry Dementiev a8b8a8c0c9 fix(cuda): учтён отказ по неподдерживаемому типу вычислений 2026-09-21 23:20:51 +03:00
Dmitry Dementiev 453b5d9dfa docs(cuda): описаны установка extra и проверка Windows DLL 2026-09-21 22:33:51 +03:00
Dmitry Dementiev 99ed5ed92f feat(cuda): добавлен optional runtime для Linux и Windows 2026-09-21 22:31:30 +03:00
Dmitry Dementiev 937eafe8db fix(device): закреплён ONNX CPU для автоматического выбора 2026-09-21 22:20:27 +03:00
Dmitry Dementiev 2b54f5bc9e docs(architecture): согласовано владение выполнением транскрипции 2026-09-21 21:55:04 +03:00
Dmitry DementievandClaude Opus 5 671ac8e5c9 docs(readme): уточнена установка из клона и пункт меню «Отправить»
- Зачем:
  - коллеги ставят инструмент из внутреннего репозитория с другим URL.
- Что:
  - добавлен вариант установки и обновления из склонированного репозитория.
  - шаг с --install-menu вынесен в список установки, шаги перенумерованы.
  - добавлена подсказка открыть новую консоль после установки uv.
- Проверка:
  - git diff --check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vByFj9s7qBHB7QRCf6mri
2026-09-14 11:24:10 +03:00
Dmitry DementievandClaude Opus 5 3860f52eb9 docs(claude): добавлены правила стиля и выбора подхода
- Зачем:
  - агенту нужны локальные правила изложения и экономии токенов в этом репозитории.
- Что:
  - добавлены разделы про стиль изложения и удержание выбранного подхода.
  - добавлены указания для Fable 5/5.1: точечные правки и пакетные запросы.
- Проверка:
  - git diff --check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vByFj9s7qBHB7QRCf6mri
2026-09-14 11:24:10 +03:00
ddmitry f3d0293120 Merge pull request 'experiment(diarization): сопоставлены W0 и Sortformer на Ryzen' (#53) from experiment/46-streaming-sortformer-ryzen into master
Reviewed-on: #53
2026-09-04 22:08:02 +03:00
Dmitry Dementiev 78960120fd docs(benchmark): добавлено сравнение W0 и Sortformer
- Зачем:
  - формальный stop W0 не отвечал на практический вопрос о скорости и качестве кандидата.
- Что:
  - добавлены результаты одной диагностической пары на трех фрагментах.
  - сопоставлены скорость, число кластеров, purity, слова без говорящего и память.
  - границы вывода отделены от формального расписания эксперимента.
- Проверка:
  - сравнительные агрегаты сверены с закрытыми результатами обеих моделей.
  - git diff --check.
2026-09-04 22:00:31 +03:00
Dmitry Dementiev b8bb78103a docs(benchmark): зафиксирован stop Ryzen-прогона
- Зачем:
  - задача #46 должна завершиться воспроизводимым решением по каноническому Ryzen-эксперименту.
- Что:
  - записан stop из-за несовпадения числа кластеров свежего W0.
  - сохранены безопасные агрегаты W0 и вторичный провал проверки swap.
  - отмечено, что Sortformer не запускался и данных об ускорении нет.
- Проверка:
  - uv run pytest -q.
  - независимая сверка отчета с закрытым результатом и проверка литературного русского.
2026-09-04 21:38:42 +03:00
Dmitry Dementiev 4c4a8be07b fix(experiment): уточнено определение активной подкачки
- Зачем:
  - занятые, но неизменные swap-страницы ошибочно блокировали канонический Ryzen-прогон.
- Что:
  - active swap определяется изменением счетчиков sin/sout во время ячейки.
  - сохранены memory gates для OOM, RSS plateau, линейного роста и пикового RSS.
- Проверка:
  - uv run pytest -q tests/test_streaming_sortformer_experiment.py tests/test_benchmark_experiment.py tests/test_speaker_diarizer.py tests/test_diarization.py.
2026-09-04 18:50:31 +03:00
Dmitry Dementiev d71aec4ab2 fix(experiment): устранен повторный gate принятия опоры
- Зачем:
  - канонический Ryzen-прогон не должен повторно запрашивать уже данное решение владельца до порога 30%.
- Что:
  - восстановлен короткий контракт approval и удалена лишняя схема owner-package.
  - разрешена парная nullable-опора для диагностик без ослабления обязательных gates.
  - NUL в путях отклоняется обезличенной ошибкой до загрузки моделей.
- Проверка:
  - uv run pytest -q: 457 passed, 2 skipped.
  - обязательный набор эксперимента: 110 passed; целевой Ruff пройден.
  - независимые code и task/taste review закрыты.
2026-09-04 17:48:36 +03:00
Dmitry Dementiev 0cd105927b chore(repo): ветка синхронизирована с origin/master
- Зачем:
  - source commit эксперимента должен включать актуальное состояние master.
- Что:
  - в рабочую ветку влиты изменения конфигурации диаризации и документации.
- Проверка:
  - слияние завершено без конфликтов, рабочее дерево чистое.
2026-09-04 17:21:42 +03:00
Dmitry Dementiev 16e5372f2d chore(repo): новые материалы .scratch исключены из Git
Зачем:
- хранить локальные handoff и launcher в постоянном каталоге без риска случайного коммита
- не терять временно закрепленный стенд диаризации между компьютерами

Что:
- весь корневой .scratch добавлен в ignore
- существующие диагностические скрипты оставлены отслеживаемыми
- описание локальных и переносимых артефактов приведено к новой политике

Проверка:
- git check-ignore -v --no-index .scratch/new-local-artifact
- git ls-files .scratch
- git diff --check
2026-09-04 16:01:39 +03:00
ddmitry 7d672a7dc8 Merge pull request 'feat(config): добавлена настройка диаризации в TOML' (#52) from fix/51-diarize-config into master
Reviewed-on: #52
2026-09-02 14:11:19 +03:00
Dmitriy Dementiev 2e30d668ce refactor(config): уточнены типы каскада настроек
- Зачем:
  - общий union допускал несоответствие между ключом настройки и его типом.
- Что:
  - разделены типы TOML, CLI и полностью разрешённой конфигурации.
  - каскад значений сохранён в типобезопасной generic-функции.
- Проверка:
  - mypy src/local_transcriber/config.py.
  - pytest tests/test_config.py tests/test_cli.py: 109 passed.
2026-09-02 12:52:33 +03:00
Dmitriy Dementiev ef61ced2e5 feat(config): добавлена настройка диаризации в TOML
- Зачем:
  - пользователю нужна постоянная настройка диаризации с явным CLI-переопределением.
- Что:
  - добавлен булев ключ diarize и каскад CLI > TOML > false.
  - добавлены --no-diarize и проверка конфликта с --speakers.
  - обновлены документация и поведенческие тесты single/batch.
- Проверка:
  - uv run pytest tests/test_config.py tests/test_cli.py -v.
  - uv run pytest: 452 passed, 1 skipped, 1 несвязанный Windows-сбой.
2026-09-02 12:45:57 +03:00
ddmitry 5ec9c77dfa Merge pull request 'docs(install): указан Python 3.13 для uv tool' (#50) from codex/docs-uv-python into master
Reviewed-on: #50
2026-09-02 12:16:33 +03:00
Dmitriy Dementiev 78f2a8aecc docs(install): указан Python 3.13 для uv tool
- Зачем:
  - uv tool install сохранял Python 3.11 старого окружения и отклонял пакет с требованием Python 3.13.
- Что:
  - в команды установки и обновления добавлен явный выбор Python 3.13.
  - актуализировано описание выбора Python в спецификации.
- Проверка:
  - git diff --check.
2026-09-02 12:11:57 +03:00
Dmitry Dementiev e71b2d1668 feat(experiment): добавлен gate принятия опоры владельцем
Зачем:
- не допустить загрузку W0 и Sortformer без отдельного принятия трёх референсов владельцем
- связать manifest с точными байтами listening package и owner-authored record

Что:
- добавлены строгие схемы трёх reference rows и четырёх решений Step 3
- все внешние файлы проверяются через held fd без symlink и TOCTOU
- добавлены negative tests для подмен, неточных JSON-типов и небезопасных путей

Проверка:
- uv run pytest -q (532 passed, 2 skipped)
- uv run ruff check src/local_transcriber/streaming_sortformer_experiment.py tests/test_streaming_sortformer_experiment.py
- git diff --check
2026-09-02 03:20:42 +03:00
Dmitry Dementiev 0f67da8acf test(diarization): изолирована проверка кеша моделей
- Зачем:
  - детерминированный gate эксперимента не должен загружать native runtime в checksum-only тесте.
- Что:
  - в тест проверки поврежденного кеша добавлена структурная подмена sherpa_onnx.
- Проверка:
  - uv run pytest -q tests/test_streaming_sortformer_experiment.py tests/test_benchmark_experiment.py tests/test_speaker_diarizer.py tests/test_diarization.py.
2026-09-01 22:32:21 +03:00
ddmitry 28d0f37b82 Merge pull request 'docs(diarization): опубликован Intel-scout Streaming Sortformer' (#49) from research/46-sortformer-intel-scout into master
Reviewed-on: #49
2026-09-01 22:07:43 +03:00
Dmitry Dementiev fa9908af6f docs(diarization): опубликован Intel-scout Streaming Sortformer
- Зачем:
  - результат локальной разведки нужен для решения о целесообразности Ryzen-прогона.
- Что:
  - опубликованы безопасные агрегаты скорости, памяти и качества.
  - зафиксированы отклонения от приемочной спецификации и открытый статус #46.
  - добавлены хеши закрытых результатов, моделей, runtime и runner-скрипта.
- Проверка:
  - privacy_scan для публичного Markdown прошел.
  - git diff --cached --check прошел.
2026-09-01 21:42:25 +03:00
ddmitry f59efa51b7 Merge pull request 'feat(diarization): добавлен стенд Streaming Sortformer' (#48) from issue-46-streaming-sortformer-experiment into master
Reviewed-on: #48
2026-09-01 20:19:05 +03:00
Dmitry Dementiev cca09aedde feat(diarization): добавлен стенд Streaming Sortformer
- Зачем:
  - для эксперимента из задачи #46 нужен изолированный и воспроизводимый стенд.
- Что:
  - добавлены строгая схема, предварительные проверки и конечный автомат;
  - зафиксированы расписание A/B, пороги, измерения, C ABI и правила приватности;
  - экспериментальный модуль исключён из production wheel.
- Проверка:
  - целевые тесты: 105 passed;
  - полный набор: 438 passed, 2 skipped;
  - Ruff: All checks passed.

Refs: #46
2026-09-01 19:34:39 +03:00
ddmitry b2a2d7d7da Merge pull request 'docs(diarization): спроектировать end-to-end карту' (#47) from research/issue-45-end-to-end-cpu-diarization-map into master
Reviewed-on: #47
2026-09-01 16:52:49 +03:00
Dmitry Dementiev 15f88b1de8 docs(diarization): отложено удаление архивов
- Зачем:
  - старые результаты решено сохранить до завершения и приемки задачи #46.
- Что:
  - архивы задач #41/#42 закреплены как временные и доступные только для чтения;
  - стенду #46 запрещено читать архивы или использовать их как основу A/B;
  - удаление перенесено на момент после приемки нового отчета.
- Проверка:
  - выполнены git diff --check и проверка формулировки в задаче #46.
2026-09-01 16:36:11 +03:00
Dmitry Dementiev 89475b9b97 docs(diarization): спроектирована end-to-end карта
- Зачем:
  - локальные оптимизации каскада не достигли 30%-го порога ускорения;
  - старые закрытые хранилища и капсулы не должны стать будущей зависимостью.
- Что:
  - собран единый реестр результатов задач #41 и #42;
  - опубликовано разложение Ryzen baseline и выбрана архитектурная гипотеза;
  - подготовлены самодостаточная спецификация и ready-for-agent задача #46.
- Проверка:
  - пройдены self-review и две независимые линии ревью;
  - проверены 44 строки реестра, 42 арифметических инварианта и ссылки;
  - выполнены ai-text-lint, privacy scan и post-lint guard.

Refs: #45
2026-09-01 16:26:58 +03:00
ddmitry 312153f4bc Merge pull request 'feat(diarization): завершить Ryzen-этап CPU-эксперимента' (#44) from issue-42-ryzen-cpu-experiment into master
Reviewed-on: #44
2026-09-01 12:53:56 +03:00
Dmitry Dementiev 249b970cc9 docs(diarization): расширен отчет Ryzen-эксперимента
Зачем:
- Сохранить полный ход проверки гипотез и исключить ненужный повтор старой матрицы.

Что:
- Добавлен ledger этапов W/M/Q/E/C с рецептами, числами и причинами отсева.
- Зафиксированы правила переиспользования результатов и границы повторных прогонов.
- Связана следующая end-to-end карта в задаче #45.

Проверка:
- uv run --no-sync pytest -q (389 passed, 2 skipped).
- Codex taste-review и ai-text-lint: APPROVED.
2026-09-01 01:36:45 +03:00
Dmitry Dementiev 38c99855e2 feat(diarization): завершен Ryzen-этап CPU-эксперимента
Зачем:
- Проверить переносимость Intel-handoff на Ryzen и закрыть выбор CPU-профиля.

Что:
- Усилена проверка связи Ryzen-manifest с капсулой и переносимости рецептов.
- Добавлены регрессионные тесты и публичный отчет с исходом new map.

Проверка:
- uv run --no-sync pytest -q (389 passed, 2 skipped).
- uv run --no-sync ruff check src/local_transcriber/benchmark_experiment.py tests/test_benchmark_experiment.py.
2026-09-01 00:58:46 +03:00
ddmitry 68fda7ee89 Merge pull request 'feat(diarization): проверен Intel-профиль CPU-диаризации' (#43) from issue-41-intel-cpu-experiment into master
Reviewed-on: #43
2026-08-31 17:14:41 +03:00
Dmitry Dementiev 42f094ccb9 docs(diarization): опубликован отчет Intel-этапа 2026-08-31 16:49:09 +03:00
Dmitry Dementiev 6345cb6449 fix(diarization): связана переносимая проверка handoff
Зачем:
- капсула должна содержать каждый вход, названный в manifest
- Intel handoff нужно проверять на Ryzen без сравнения чужого SKU

Что:
- входы manifest связаны с индексом и SHA-256 файлов капсулы
- validate-only финального handoff пропускает только проверку машины
- версии Python, пакетов и экспериментальной сборки по-прежнему обязательны

Проверка:
- uv run pytest -q tests/test_benchmark_experiment.py
- uv run ruff check для изменённых файлов
2026-08-31 15:55:40 +03:00
Dmitry Dementiev 0ea4d56c9d fix(diarization): учтён системный power profile
Зачем:
- часть Intel-стендов не публикует ACPI platform_profile
- balanced-режим на таком стенде доступен через power-profiles-daemon

Что:
- добавлен fallback на powerprofilesctl get
- поведение закреплено тестом

Проверка:
- uv run pytest -q tests/test_benchmark_experiment.py
- uv run ruff check для изменённых файлов
2026-08-31 15:16:10 +03:00
Dmitry Dementiev bd0d7e6736 feat(diarization): подготовлен стенд Intel-эксперимента
Зачем:
- нужен воспроизводимый Intel-этап перед проверкой кандидатов на Ryzen
- sherpa-onnx 1.13.6 открывает настройку шага окна

Что:
- добавлены строгий manifest v3, планировщик A/B и проверяемая приватная капсула
- реализованы экспериментальные NME, модели эмбеддингов и режимы worker/session
- версия sherpa-onnx поднята до 1.13.6, зависимости и тесты закреплены

Проверка:
- uv lock --check
- uv run pytest -q
- uv run ruff check для изменённых Python-файлов
- uv build с проверкой состава wheel
2026-08-31 15:12:33 +03:00
Dmitry Dementiev 7dc5723742 docs(research): добавлено сравнение ASR-моделей для русских встреч
- Зачем:
  - нужно оценить локальные ASR-кандидаты для русских встреч с английскими IT-терминами.
- Что:
  - собраны первичные данные по GigaAM, Nemotron, Qwen3-ASR, Canary и Cohere.
  - разделены известные факты, рабочие гипотезы, рекомендация и неизвестные.
  - сопоставлены языки, таймкоды, режимы работы, поставка и лицензии.
- Проверка:
  - выполнено горячее ревью документа и git diff --cached --check.
2026-08-31 01:08:36 +03:00
ddmitry 18d1094d10 Merge pull request 'docs(diarization): спроектирован следующий CPU-эксперимент' (#40) from wayfinder/issue-34-cpu-experiment-matrix into master
Reviewed-on: #40
2026-08-31 01:05:07 +03:00
Dmitry Dementiev 558c1baf89 docs(diarization): разделен CPU-эксперимент по машинам
- Зачем:
  - один агент не может последовательно работать на двух ноутбуках.
- Что:
  - этапы 0-4 закреплены за Intel, этапы 5-6 - за зависимой Ryzen-задачей.
  - добавлен проверяемый handoff через отчет, commit и приватную ZIP-капсулу.
  - устранен штатный возврат с Ryzen на Intel и ограничен финальный бюджет.
- Проверка:
  - `.venv/bin/pytest -q tests/test_diarization_calibration.py tests/test_speaker_diarizer.py tests/test_diarization.py` (61 passed).
2026-08-31 00:59:23 +03:00
Dmitry Dementiev fde4c48859 docs(diarization): спроектирован следующий CPU-эксперимент
- Зачем:
  - нужен ограниченный эксперимент для отсева явных проигрышей без полного перебора и лишней точности.
- Что:
  - зафиксирована поэтапная матрица проверок на Intel и Ryzen.
  - определены пороги скорости, условия качества, контроль памяти и приватности.
  - описаны воспроизводимость стенда, QDQ-происхождение данных и правила решения.
- Проверка:
  - `.venv/bin/pytest -q tests/test_diarization_calibration.py tests/test_speaker_diarizer.py tests/test_diarization.py` (61 passed).
2026-08-31 00:16:09 +03:00
ddmitry e7c6eacb56 Merge pull request 'docs(research): выбрано обновление sherpa-onnx для шага окна' (#39) from research/issue-36-window-shift into master
Reviewed-on: #39
2026-08-30 23:16:31 +03:00
Dmitry Dementiev d525f5b18a docs(research): уточнены границы поддержки sherpa-onnx
- Зачем:
  - вывод об обычном обновлении должен опираться на проверяемые API и wheel для поддерживаемых сред.
- Что:
  - исправлены ссылки на tagged source и уточнены границы manylinux и будущего CPython 3.15.
  - зафиксирован smoke test CPython 3.13 и практическая польза обновления.
  - вычищены двусмысленные формулировки без изменения фактов и чисел.
- Проверка:
  - uv run pytest -q: 334 passed, 2 skipped.
  - 23 внешние ссылки отвечают HTTP 200; git diff --check.
2026-08-30 23:08:31 +03:00
Dmitry Dementiev a53344dc04 docs(research): исследован шаг окна sherpa-onnx
- Зачем:
  - требовалось определить первый wheel с публичным API для шага окна.
- Что:
  - зафиксирована граница 1.13.5/1.13.6 и матрица Python/платформ.
  - выбрано обычное обновление и задана точная граница прототипа.
- Проверка:
  - git diff --cached --check.
2026-08-30 23:08:31 +03:00
ddmitry 13314f9334 Merge pull request 'docs(research): выбрана схема оценки числа говорящих' (#38) from research/issue-33-speaker-count into master
Reviewed-on: #38
2026-08-30 21:58:59 +03:00
Dmitry Dementiev 66fc264f2d docs(research): уточнена граница прототипа оценки говорящих
- Зачем:
  - исследовательское решение должно однозначно задавать повторное использование эмбеддингов и проверку счетчиков.
- Что:
  - выбран внутренний интерфейс подготовки и финализации sherpa-onnx.
  - закреплены входы, вариант NME и критерии качества.
  - измерения CPU и памяти отделены от будущих гейтов карты.
- Проверка:
  - `uv run pytest -q` (334 passed, 2 skipped).
  - `git diff --check`.
  - 31 внешняя ссылка отвечает HTTP 200.
2026-08-30 21:50:51 +03:00
ddmitry 9fc2dc0c6c Merge pull request 'docs(research): опубликовано исследование embedding-инференса' (#37) from docs/publish-diarization-research into master
Reviewed-on: #37
2026-08-30 21:07:24 +03:00
Dmitry Dementiev 6f0adf2eb0 docs(cpu): вычищены формулировки аппаратных baseline
- Зачем:
  - README и backlog должны одинаково и однозначно описывать границы CPU-замеров перед merge.
- Что:
  - Замер Ryzen ограничен одной зафиксированной тестовой машиной.
  - Из backlog убраны повтор и искусственные противопоставления вокруг целевого SKU.
- Проверка:
  - `git diff --check`.
2026-08-30 21:05:40 +03:00
Dmitry Dementiev bcf264e0aa docs(research): удалена преждевременная записка
- Зачем:
  - Исследование числа говорящих не проходило отдельный Wayfinder-тикет и не должно публиковаться как принятое решение.
- Что:
  - Записка об оценке числа говорящих удалена из publication-ветки.
- Проверка:
  - `git diff --check`.
2026-08-30 21:04:03 +03:00
Dmitry Dementiev 2fc777fdee docs(cpu): уточнена аппаратная матрица
- Зачем:
  - Конкретный Core i5 ошибочно стал обязательной приемочной машиной при разнородном парке пользователей.
- Что:
  - CPU-дефолт отделен от воспроизводимых Intel и AMD baseline.
  - README, backlog и исследование приведены к единой терминологии.
- Проверка:
  - `git diff --check`.
2026-08-30 20:58:18 +03:00
Dmitry Dementiev b09036eaa3 docs(research): выбрана схема оценки числа говорящих
- Зачем:
  - быстрым моделям эмбеддингов нужен автоматический подсчет без повторного инференса.
- Что:
  - проверены возможности повторного использования эмбеддингов в публичном API sherpa-onnx.
  - сопоставлены порог, eigengap, NME-SC и двухшаговая кластеризация.
  - зафиксирована граница NumPy-прототипа без production-зависимости от NeMo.
- Проверка:
  - git diff --cached --check.
2026-08-30 20:56:21 +03:00
Dmitry Dementiev 5c6697ecbf docs(research): уточнены batch-контракты embedding-моделей
- Зачем:
  - вопрос тикета требует проверить формы официальных release-графов, а исходная записка опиралась только на экспортные контракты.
- Что:
  - зафиксированы реальные формы и CPU smoke-test batch size 2 для WeSpeaker, CAMPPlus и TitaNet.
  - уточнены поддержка equal-length batch у CAMPPlus и fallback между S8S8, U8U8 и U8S8.
- Проверка:
  - git diff --check.
2026-08-30 20:56:21 +03:00
Dmitry Dementiev bfabf09c83 docs(research): добавлено исследование embedding-инференса
- Зачем:
  - нужно выбрать реалистичный способ ускорения эмбеддингов на разных x86 CPU.
- Что:
  - зафиксированы ограничения batch, stream/session и static INT8 для трех моделей.
  - рекомендованы два прототипа и минимальная матрица CPU/ISA.
- Проверка:
  - git diff --cached --check.
2026-08-30 20:56:21 +03:00
ddmitry f5c1cb8090 Merge pull request 'test(diarization): добавлен стенд сравнения CPU-моделей' (#30) from issue-25-lightweight-cpu-diarization into master
Reviewed-on: #30
2026-08-30 18:50:50 +03:00
Dmitry Dementiev e3c9040bfe docs(research): выбрана схема оценки числа говорящих
- Зачем:
  - быстрым моделям эмбеддингов нужен автоматический подсчет без повторного инференса.
- Что:
  - проверены возможности повторного использования эмбеддингов в публичном API sherpa-onnx.
  - сопоставлены порог, eigengap, NME-SC и двухшаговая кластеризация.
  - зафиксирована граница NumPy-прототипа без production-зависимости от NeMo.
- Проверка:
  - git diff --cached --check.
2026-08-30 13:42:42 +03:00
60 changed files with 15839 additions and 1894 deletions

No files matched your search

+1 -1
View File
@@ -7,4 +7,4 @@ dist/
.codex
.qwen/
tmp
.scratch/handoffs/
/.scratch/
+5
View File
@@ -5,6 +5,11 @@
Не часть пакета: они опираются на `sherpa-onnx`, которого нет в зависимостях
проекта, и живут в `.scratch/`, а не в `src/`.
Код этого каталога временно закреплен в Git: его используют несколько задач на
нескольких компьютерах, пока идет исследование диаризации. Общее правило
`/.scratch/` относится к новым локальным материалам; уже отслеживаемые файлы
этого стенда продолжают версионироваться.
Результаты первого прогона описаны в
[разведочном замере](../../docs/benchmarks/2026-08-12-diarization-feasibility.md).
+13 -10
View File
@@ -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
+20
View File
@@ -1 +1,21 @@
@AGENTS.md
# Стиль изложения
Пиши легко для чтения. Сокращай количество мыслей, а не количество слов, которыми выражена каждая мысль.
Please remove all mannered prose.
# Выбрать подход и держаться его
Выбирая, как решать задачу, возьми один подход и доведи его до конца. Не пересматривай
решение, пока не появится новая информация, которая прямо противоречит твоим
рассуждениям. Если колеблешься между двумя подходами, выбери один и посмотри, куда он
приведёт; свернуть можно позже, если выбранный не сработал.
# Для моделей Fable 5 и 5.1
При прочих равных экономь токены на правке файлов: когда результат не меняется, правь
файл точечно, а не переписывай целиком.
Сначала про себя составь список того, что нужно дальше; затем в одном ответе запроси
всё, что не зависит от результата другого запроса.
+8 -8
View File
@@ -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()` — фабрики тестовых данных
## Частые задачи
+137 -40
View File
@@ -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).
+6 -6
View File
@@ -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 Подробный вывод (прогресс сегментов)
@@ -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
View File
@@ -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 в эту задачу не входит.
+24 -16
View File
@@ -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` флаг для перезаписи существующих транскриптов в батч-режиме
+34 -15
View File
@@ -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
+5 -3
View File
@@ -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. Несовместимый язык работу не
-1
View File
@@ -5,7 +5,6 @@
- Основной remote: `origin`
- Gitea: `https://git.dementev.space`
- CLI: `tea`
- Remote `github` является зеркалом и не используется для управления задачами
- Внешние pull request не входят в очередь triage
## Доступ
+24 -11
View File
@@ -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,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
View File
@@ -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,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.
+6 -6
View File
@@ -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`
+6 -4
View File
@@ -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 и качеством диаризации сюда не входит.
+19 -4
View File
@@ -8,21 +8,31 @@ 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",
]
@@ -36,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"]
+26 -4
View File
@@ -25,7 +25,7 @@ from tempfile import NamedTemporaryFile
from typing import Any
SCHEMA_VERSION = 2
EXPECTED_SHERPA_ONNX_VERSION = "1.13.5"
EXPECTED_SHERPA_ONNX_VERSION = "1.13.6"
SAMPLE_RATE = 16_000
SAMPLE_WIDTH_BYTES = 2
CHANNELS = 1
@@ -133,7 +133,7 @@ class PeakRssSampler:
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Проверяет и запускает воспроизводимый CPU-benchmark из issue #25. "
"Проверяет и запускает воспроизводимый CPU-benchmark диаризации. "
"Приватные пути и результаты остаются вне Git."
),
epilog=(
@@ -144,7 +144,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser.add_argument(
"--manifest",
type=Path,
help="manifest v2; относительные пути считаются от его каталога",
help="manifest v2/v3; относительные пути считаются от его каталога",
)
parser.add_argument(
"--output",
@@ -156,6 +156,15 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
type=Path,
help="каталог декодированных WAV и запросов дочерним процессам",
)
parser.add_argument(
"--capsule",
type=Path,
help="исходный ZIP приватной капсулы для проверки handoff",
)
parser.add_argument(
"--capsule-sha256",
help="ожидаемый внешний SHA-256 ZIP-капсулы",
)
# Оставлено для воспроизводимости старых команд; значение входит в experiment_id.
parser.add_argument(
"--threads",
@@ -201,6 +210,8 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
missing.append("output")
if missing:
parser.error("обязательные параметры: " + ", ".join(missing))
if (args.capsule is None) != (args.capsule_sha256 is None):
parser.error("--capsule и --capsule-sha256 задаются вместе")
if args.prepare_asr is not None:
asr_missing = [
name
@@ -747,7 +758,7 @@ def select_threshold(
def parse_stage_timings(stderr: str, sherpa_version: str) -> dict[str, float]:
"""Строго разбирает недокументированный debug-контракт sherpa 1.13.5."""
"""Строго разбирает недокументированный debug-контракт sherpa 1.13.6."""
if sherpa_version != EXPECTED_SHERPA_ONNX_VERSION:
raise ValueError(
f"Неизвестный формат stage timing sherpa-onnx {sherpa_version}"
@@ -1536,9 +1547,20 @@ def validation_summary(
def main(argv: list[str] | None = None) -> None:
args = parse_args(argv)
if args.worker is not None:
request = json.loads(args.worker.read_text(encoding="utf-8"))
if request.get("worker_schema_version") == 3:
from local_transcriber.benchmark_experiment import worker_main
worker_main(args.worker)
return
_worker_main(args.worker)
return
raw_manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
if raw_manifest.get("schema_version") == 3:
from local_transcriber.benchmark_experiment import run_cli
run_cli(args, raw_manifest)
return
validate_manifest(raw_manifest, verify_files=False)
manifest = resolve_manifest_paths(raw_manifest, args.manifest.parent)
if args.threads is not None:
@@ -0,0 +1,8 @@
"""Точка входа изолированного эксперимента Streaming Sortformer."""
from __future__ import annotations
from local_transcriber.streaming_sortformer_experiment import main
if __name__ == "__main__":
main()
+28 -7
View File
@@ -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
+7
View File
@@ -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:
+46 -2
View File
@@ -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
+108 -194
View File
@@ -11,7 +11,11 @@ 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
@@ -33,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,
@@ -47,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,
)
@@ -59,6 +61,13 @@ app = typer.Typer()
console = Console(stderr=True)
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,
@@ -82,21 +91,6 @@ def _with_cli_warning_renderer(
return wrapped
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)"
if device_used == "onnx":
return "ONNX (CPU)"
return "CPU"
def _format_language_mode(requested_language: str, result: TranscribeResult) -> str:
"""Описывает источник языка, не выдавая профиль модели за детектор."""
if requested_language != "auto":
@@ -261,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(
@@ -328,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:
@@ -358,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:
@@ -383,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:
@@ -406,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:
@@ -420,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]")
@@ -430,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
@@ -509,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,
@@ -552,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:
@@ -586,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
@@ -647,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
@@ -718,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,
@@ -743,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:
+87 -36
View File
@@ -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
+239 -117
View File
@@ -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"
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 device != "cpu" and _is_backend_error(exc, device):
if strict_device:
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,
def transcribe(
self,
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
) -> TranscribeResult:
"""Транскрибирует один файл; при разрешённом fallback перезагружает модель."""
self.prepare(on_status)
lang_arg = self._request.language or None
if lang_arg == "auto":
lang_arg = None
try:
_notify_status(on_status, "Транскрибирую...")
result = backend.transcribe(model, file_path, lang_arg, on_segment, on_status)
result.device_used = actual_device
result = self._recognize(file_path, lang_arg, on_segment, on_status)
except (RuntimeError, ValueError) as exc:
if actual_device != "cpu" and _is_backend_error(exc, actual_device):
if strict_device:
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
"""Публичный путь одного файла через тот же 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):
-10
View File
@@ -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
+11 -5
View File
@@ -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 "не установлен"
+34
View File
@@ -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"] == "по умолчанию"
+18
View File
@@ -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
+405 -871
View File
File diff suppressed because it is too large. Load diff
+34 -74
View File
@@ -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"
+64 -3
View File
@@ -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:
+6 -6
View File
@@ -137,7 +137,7 @@ def _manifest(tmp_path):
def _environment():
return {
"sherpa_onnx_version": "1.13.5",
"sherpa_onnx_version": "1.13.6",
"onnxruntime_version": "1",
"numpy_version": "2",
"psutil_version": "7",
@@ -408,7 +408,7 @@ def test_stage_timing_parser_accepts_native_lines():
"[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.5")
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
@@ -424,7 +424,7 @@ def test_stage_timing_parser_accepts_native_lines():
)
def test_stage_timing_parser_rejects_missing_duplicate_and_negative(log):
with pytest.raises(ValueError):
calibration.parse_stage_timings(log, "1.13.5")
calibration.parse_stage_timings(log, "1.13.6")
def test_stage_timing_parser_rejects_extra_malformed_record():
@@ -436,7 +436,7 @@ def test_stage_timing_parser_rejects_extra_malformed_record():
"OfflineSpeakerDiarization: total 3.100 s, audio 10.000 s, RTF 0.310"
)
with pytest.raises(ValueError, match="повреждённую"):
calibration.parse_stage_timings(log, "1.13.5")
calibration.parse_stage_timings(log, "1.13.6")
def test_measurement_uses_injected_cpu_clock_and_rss_once():
@@ -708,7 +708,7 @@ 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.5"}}
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 = " СОВЕРШЕННО-СЕКРЕТНЫЙ-ТЕКСТ"
@@ -758,7 +758,7 @@ def test_execute_cell_rejects_text_mismatch_without_persisting_private_text(
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.5"}}
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(
+677
View File
@@ -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
View File
@@ -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"
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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"
Generated
+162 -51
View File
@@ -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,28 +300,41 @@ 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 = "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" },
]
@@ -324,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"
@@ -380,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]]
@@ -516,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"
@@ -525,22 +652,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[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/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.169Z" },
{ 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.977Z" },
{ 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.795Z" },
{ 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.343Z" },
{ 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.515Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
@@ -626,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]]