feat(diarization): добавлено разделение транскрипта по говорящим #26

Merged
ddmitry merged 2 commits from codex/24-word-diarization into master 2026-08-14 21:57:45 +03:00
25 changed files with 2755 additions and 187 deletions
+44
View File
@@ -10,6 +10,7 @@ transcribe meeting.mp4
- **Полностью локально** — данные не покидают машину - **Полностью локально** — данные не покидают машину
- **Авто-ускорение** — NVIDIA CUDA при наличии GPU, иначе ONNX на CPU - **Авто-ускорение** — NVIDIA CUDA при наличии GPU, иначе ONNX на CPU
- **Батч-режим** — обработка нескольких файлов за один вызов - **Батч-режим** — обработка нескольких файлов за один вызов
- **Разделение говорящих** — локальная диаризация по флагу `--diarize`
- **Из проводника Windows** — пункт Transcribe в меню «Отправить» ([установка](#контекстное-меню-проводника-windows)) - **Из проводника Windows** — пункт Transcribe в меню «Отправить» ([установка](#контекстное-меню-проводника-windows))
- **Markdown с таймкодами** — удобен для суммаризации ИИ - **Markdown с таймкодами** — удобен для суммаризации ИИ
- **Аудио и видео** — mp3, wav, mp4, mkv и [другие форматы](#поддерживаемые-форматы) - **Аудио и видео** — mp3, wav, mp4, mkv и [другие форматы](#поддерживаемые-форматы)
@@ -135,8 +136,36 @@ transcribe meeting.wav --device onnx --model gigaam-multilingual-large-ctc
# Сохранить в конкретный файл # Сохранить в конкретный файл
transcribe interview.m4a --output result.md transcribe interview.m4a --output result.md
# Разделить встречу на реплики говорящих
transcribe meeting.mp4 --diarize
# Если число участников известно заранее
transcribe interview.m4a --speakers 2
``` ```
### Разделение говорящих
`--diarize` добавляет к транскрипту реплики `Speaker 1`, `Speaker 2` и так
далее. `--speakers N` задаёт ожидаемое число участников и автоматически включает
диаризацию; без него число кластеров определяется автоматически.
При первом таком запуске дополнительно скачиваются две ONNX-модели Sherpa-ONNX:
сегментация (~6 МБ) и голосовые эмбеддинги (~27 МБ). Они сохраняются в кеше
Hugging Face и используются повторно. Диаризация выполняется после распознавания
речи и добавляет отдельный проход по записи. На измеренном слабом Intel Core
i7-6820HQ последовательные ASR и диаризация увеличивали полное время примерно в
2,4 раза, но оставались быстрее реального времени; фактическая скорость зависит
от процессора и режима питания ([замеры](docs/benchmarks/2026-08-14-diarization-intel-i7.md)).
Если найдено меньше двух говорящих или диаризация конкретного файла завершилась
ошибкой, текст не теряется: сохраняется обычный транскрипт, в Markdown
записывается причина, а команда завершается с кодом `1`. Если выбранный ASR-путь
не поддерживает пословные таймкоды или диаризатор не удалось инициализировать,
запуск останавливается до первого ASR и не создаёт частичных транскриптов. Малый
кластер только отмечается предупреждением и не удаляется. Слова без однозначного
говорящего попадают в реплику `Speaker ?`.
### Батч-режим ### Батч-режим
Обработка нескольких файлов за один вызов — модель загружается один раз: Обработка нескольких файлов за один вызов — модель загружается один раз:
@@ -155,6 +184,8 @@ transcribe *.mp4 --force
- Файлы с существующим транскриптом (`*-transcript.md`) автоматически пропускаются - Файлы с существующим транскриптом (`*-transcript.md`) автоматически пропускаются
- `--force` / `-f` — перезаписать существующие транскрипты - `--force` / `-f` — перезаписать существующие транскрипты
- При ошибке в одном файле остальные продолжают обрабатываться - При ошибке в одном файле остальные продолжают обрабатываться
- При ошибке диаризации сохраняется обычный транскрипт, остальные файлы
продолжают обрабатываться; итоговый код батча — `1`
- `--output` несовместим с несколькими файлами - `--output` несовместим с несколькими файлами
### Контекстное меню проводника (Windows) ### Контекстное меню проводника (Windows)
@@ -192,6 +223,8 @@ transcribe --uninstall-menu
| `--device` | `-d` | `auto` | Устройство (auto, cpu, cuda, openvino, openvino-gpu, openvino-cpu, onnx) | | `--device` | `-d` | `auto` | Устройство (auto, cpu, cuda, openvino, openvino-gpu, openvino-cpu, onnx) |
| `--compute-type` | — | float16 (CUDA) / int8 (ONNX/OpenVINO) / float32 (CPU) | Тип вычислений | | `--compute-type` | — | float16 (CUDA) / int8 (ONNX/OpenVINO) / float32 (CPU) | Тип вычислений |
| `--threads` | `-t` | 0 (авто) | Потоки CPU (рекомендуется = число физ. ядер) | | `--threads` | `-t` | 0 (авто) | Потоки CPU (рекомендуется = число физ. ядер) |
| `--diarize` | — | — | Разделить текст на реплики говорящих |
| `--speakers` | — | авто | Ожидаемое число говорящих; включает `--diarize` |
| `--force` | `-f` | — | Перезаписать существующие транскрипты | | `--force` | `-f` | — | Перезаписать существующие транскрипты |
| `--verbose` | `-v` | — | Подробный вывод | | `--verbose` | `-v` | — | Подробный вывод |
@@ -404,6 +437,17 @@ device-aware дефолт недоступен для выбранной мод
Если язык определить не удалось, строка выглядит так: `- **Язык**: не определён`. Если язык определить не удалось, строка выглядит так: `- **Язык**: не определён`.
С `--diarize` при успешном обнаружении нескольких говорящих основная часть
выглядит так:
```markdown
[00:00] Speaker 1: Добрый день, коллеги.
[00:04] Speaker 2: Начнём с результатов квартала.
```
Таймкод реплики показывает начало: `MM:SS`, а после часа — `HH:MM:SS`.
</details> </details>
## Поддерживаемые форматы ## Поддерживаемые форматы
@@ -45,6 +45,39 @@ WeSpeaker ResNet34 LM проверена на трёх записях с изв
[смешении говорящих](../benchmarks/2026-08-14-asr-segment-speaker-mixing.md) и [смешении говорящих](../benchmarks/2026-08-14-asr-segment-speaker-mixing.md) и
[производительности на Intel](../benchmarks/2026-08-14-diarization-intel-i7.md). [производительности на Intel](../benchmarks/2026-08-14-diarization-intel-i7.md).
### Сглаживание неизвестного говорящего
После приёмки отдельно проверена идея автоматически назначать `Speaker ?`
известному говорящему, если короткий неизвестный фрагмент находится между двумя
репликами одного и того же `Speaker N`. На трёх контрольных транскриптах найдено
143 таких неизвестных фрагмента, содержащих 309 слов. У 79 фрагментов соседи
имели одинаковую метку, у 64 — разные.
Для T2 BDMA и Yantar результат грубо сопоставлен с независимыми Hypescribe-
транскриптами. Это не gold-разметка: их границы округлены до секунды, а готовый
speaker Markdown хранит только начало реплики. Тем не менее эвристика не показала
достаточной селективности:
| Эвристика | Изменённых фрагментов на трёх записях | Совпадение на двух записях с опорой |
|---|---:|---:|
| Все неизвестные между одинаковыми соседями | 79 | 22 / 42 (52%) |
| Только один неизвестный word | 35 | 11 / 20 (55%) |
| Не больше двух words | 50 | 15 / 30 (50%) |
| Одинаковый отображённый timestamp | 42 | 14 / 22 (64%) |
Короткие `Да`, `Нет` и `Угу` часто являются самостоятельной репликой другого
участника. В одном из контрольных случаев неизвестный фрагмент `— Угу. — А`
даже содержал границу двух голосов, хотя с обеих сторон находился один и тот же
кластер. Поэтому post-hoc сглаживание по соседям скрывает полезную
неопределённость и не применяется. `Speaker ?` остаётся явным результатом ничьей
или отсутствия временного перекрытия.
Если к этой задаче возвращаться, проверять нужно исходные границы `Word` и
`SpeakerInterval` до группировки: отдельно различать нулевой timestamp, реальный
зазор между интервалами и ничью перекрытий. По готовому Markdown такая проверка
невозможна, потому что в нём уже потеряны доли секунды, конец слова и причина
неопределённости.
## Решение ## Решение
Диаризацию реализуем как явно включаемый пост-процессинг через `sherpa-onnx`. Диаризацию реализуем как явно включаемый пост-процессинг через `sherpa-onnx`.
@@ -97,3 +130,4 @@ ASR-бэкенд приводит свой результат к общему н
| `pyannote.audio` | Тянет PyTorch и требует Hugging Face token с принятием лицензии | | `pyannote.audio` | Тянет PyTorch и требует Hugging Face token с принятием лицензии |
| Сборка поверх приватных деталей `onnx-asr` | Экономит небольшую отдельную зависимость ценой нестабильного внутреннего API и собственной кластеризации | | Сборка поверх приватных деталей `onnx-asr` | Экономит небольшую отдельную зависимость ценой нестабильного внутреннего API и собственной кластеризации |
| Параллельные проходы в первой версии | Нет прямого benchmark и измеренного общего пика памяти; сначала нужен корректный последовательный baseline | | Параллельные проходы в первой версии | Нет прямого benchmark и измеренного общего пика памяти; сначала нужен корректный последовательный baseline |
| Автоматически назначать `Speaker ?` одинаковому соседнему кластеру | На двух записях с независимой опорой совпало только 52% назначений; лимиты по словам и округлённому времени не отделили короткие ответы другого участника |
@@ -0,0 +1,48 @@
# Приёмка speaker diarization в CLI
**Дата:** 2026-08-14
**Статус:** ручная приёмка реализации задачи #24 на трёх контрольных записях.
## Профиль запуска
- ASR: `onnx`, `gigaam-v3-e2e-rnnt`, русский язык;
- диаризация: Pyannote segmentation 3.0 и WeSpeaker ResNet34 LM;
- автоматическое число говорящих, порог кластеризации `0,89`;
- 8 потоков CPU, модели в локальном кеше;
- обычный ASR и запуск с `--diarize` выполнялись последовательно.
## Результаты
| Запись | Длительность | Обычный ASR | С диаризацией | Кластеры | Неназначенные слова |
|---|---:|---:|---:|---:|---:|
| Data Test | 26:00 | 137,5 с | 326,2 с | 4 (один малый, 19,1 с) | 201 |
| T2 BDMA | 14:51 | 83,0 с | 184,2 с | 2 | 65 |
| Yantar | 20:22 | 100,1 с | 259,7 с | 2 | 43 |
Все три запуска завершились быстрее реального времени. Четвёртый прогон T2
BDMA после исправления склейки пунктуации повторно подтвердил два кластера и
65 неназначенных слов.
## Инварианты
- после удаления только форматных пробелов перед Unicode-пунктуацией и
символами текст speaker-вывода на всех трёх записях в точности совпал с
обычным ASR: 21 788, 10 520 и 12 055 символов соответственно;
- повторный T2-прогон последней версии также дал точное совпадение 10 520 из
10 520 символов;
- слова не потерялись и не поменяли порядок; неизвестный говорящий остаётся в
выводе как `Speaker ?`;
- CLI показал число кластеров, неназначенные слова и малый остаточный кластер,
не скрывая диагностические данные.
## Память и стоимость
Во время финального T2-прогона рабочий процесс наблюдался на уровне 905 МБ RSS.
Это согласуется с отдельным замером последовательных проходов: 900–1035 МБ для
ASR и 366–469 МБ для диаризации. Подробные условия и ограничения приведены в
[отчёте Intel i7](2026-08-14-diarization-intel-i7.md).
Диаризация остаётся опциональной: на контрольных записях полное время выросло
примерно в 2,2–2,6 раза. При этом сбой диаризации не удаляет готовый ASR-текст и
не останавливает обработку остальных файлов batch-запуска.
@@ -0,0 +1,199 @@
# Стоимость CPU-диаризации и облегчённые альтернативы
**Дата:** 2026-08-14
**Статус:** исследовательская записка. Production-конфигурация не менялась.
## Краткий вывод
Измеренная стоимость ожидаема для выбранного каскада, но не является
неизбежным минимумом. `WeSpeaker ResNet34 LM` — не аномально большая модель:
ONNX-файл занимает 26,5 МБ, а сама архитектура находится в младшей части
семейства WeSpeaker ResNet. Основная цена возникает из способа применения
моделей: Pyannote проходит запись перекрывающимися 10-секундными окнами с шагом
1 секунда, после чего sherpa-onnx отдельно считает speaker embedding для каждого
локального говорящего каждого окна.
На минутном профиле текущей связки на этом ноутбуке эмбеддинги заняли 5,150 с
из 6,395 с, сегментация — 1,243 с, кластеризация — меньше измеримой миллисекунды.
То есть около 81% времени в этом прогоне пришлось на многократные вызовы
WeSpeaker. Ускорять только кластеризацию или менять её порог бессмысленно.
Более лёгкие пути есть. Самый безопасный для исследования — официальная INT8
версия той же Pyannote-сегментации. Самый большой подтверждённый upstream
потенциал даёт `nemo_en_titanet_small`, но её качество и порог кластеризации на
русских созвонах проекта ещё не проверялись. Локально проверенная CAMPPlus zh/en
быстрее WeSpeaker примерно на 30%, однако не прошла требование автоматического
определения числа говорящих с единым порогом.
## Откуда берётся стоимость
`sherpa-onnx` строит результат из трёх вычислительных стадий: speaker
segmentation, speaker embeddings и clustering. Это соответствует как
[официальному API](https://k2-fsa.github.io/sherpa/onnx/c-api/html/speaker_diarization.html),
так и [реализации конвейера
Pyannote](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/offline-speaker-diarization-pyannote-impl.h).
### 1. Перекрывающаяся сегментация
Pyannote segmentation 3.0 принимает 10 секунд mono 16 кГц и различает до трёх
локальных говорящих в окне, включая пары одновременно говорящих. Это явно
зафиксировано в [карточке исходной
модели](https://huggingface.co/pyannote/segmentation-3.0).
В официальном ONNX-файле `window_size=160000`, то есть те же 10 секунд при
16 кГц. В sherpa-onnx 1.13.5 `window_shift_ratio` по умолчанию равен 0,1, а
число окон вычисляется из размера окна и этого шага. Поэтому обычная длинная
запись проходит через segmentation-модель примерно десятикратно
перекрывающимися окнами: новое окно начинается каждую секунду. См.
[конфигурацию шага](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/offline-speaker-segmentation-pyannote-model-config.h)
и [цикл обработки
окон](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/offline-speaker-diarization-pyannote-impl.h).
Это не признак «огромной» модели: FP32-файл сегментации весит лишь 5,7 МБ.
Стоимость создаёт прежде всего частота её запуска. Официальная поставка также
содержит `model.int8.onnx` размером 1,5 МБ
([документация sherpa-onnx](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/models.html)).
### 2. Embedding для каждого локального говорящего каждого окна
После сегментации sherpa-onnx исключает перекрывающиеся кадры из материала для
эмбеддинга, собирает пары `(окно, локальный говорящий)` и для каждой пары создаёт
отдельный stream и запускает embedding extractor. Это видно непосредственно в
[методах `GetChunkSpeakerSampleIndexes` и
`ComputeEmbeddings`](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/offline-speaker-diarization-pyannote-impl.h).
При одном активном голосе это уже примерно один embedding на секунду записи;
если в окне модель видит несколько локальных голосов, вызовов становится больше.
Текущий extractor — английский VoxCeleb `ResNet34_LM`; WeSpeaker поясняет, что
суффикс LM означает дополнительную large-margin донастройку, полезную на
фрагментах длиннее трёх секунд
([официальный список моделей](https://github.com/wenet-e2e/wespeaker/blob/master/docs/pretrained.md)).
### 3. Кластеризация почти бесплатна
В [первичном разведочном
замере](../benchmarks/2026-08-12-diarization-feasibility.md) смена порога не
изменила время: 153–157 с, а кластеризация занимала доли секунды. Минутный профиль
текущей production-конфигурации также показал `0.000 s` для clustering. Явное
число участников полезно для качества и стабильности количества кластеров, но не
является существенной оптимизацией CPU.
## Что показывают локальные измерения
| Среда и материал | Конфигурация | Результат |
|---|---|---:|
| Ryzen 7 8845H, Data Test 26:00 | Pyannote FP32 + WeSpeaker, 8 потоков | 140,7 с; 11,1× realtime |
| Ryzen 7 8845H, три 5-минутных фрагмента | WeSpeaker | средний RTF 0,118 |
| Ryzen 7 8845H, те же фрагменты | CAMPPlus zh/en | средний RTF 0,083; 0,70× от WeSpeaker |
| Intel Core i7-6820HQ, 61:13 аудио | WeSpeaker, 8 потоков | 832,3 с; 4,4× realtime |
| Текущий ноутбук, 60 с T2 BDMA | WeSpeaker, 8 потоков, debug profile | segmentation 1,243 с; embeddings 5,150 с; clustering 0,000 с; всего 6,395 с |
Источники полных воспроизводимых замеров: [разведка на
Ryzen](../benchmarks/2026-08-12-diarization-feasibility.md), [калибровка
эмбеддингов](../benchmarks/2026-08-14-diarization-calibration.md) и [Intel
baseline](../benchmarks/2026-08-14-diarization-intel-i7.md). Минутный профиль —
диагностический одиночный прогон той же production-конфигурации на первых 60
секундах T2 BDMA; его следует использовать для распределения стоимости по
стадиям, а не как новый общий benchmark.
Увеличение `num_threads` не решает проблему. На Data Test переход с четырёх
потоков (154 с) на восемь (141 с) дал только 9%. Это согласуется с устройством
конвейера: он выполняет много последовательных ONNX-вызовов для отдельных окон
и локальных говорящих, поэтому добавление потоков внутри одного вызова быстро
перестаёт масштабироваться.
## Официально поддерживаемые облегчённые варианты
### INT8 Pyannote segmentation 3.0
Sherpa-onnx официально поставляет FP32 и INT8 варианты одной сегментации. На его
контрольной записи замена только segmentation-модели при 3D-Speaker embedding
снизила RTF с 0,297 до 0,241, то есть примерно на 19%. С TitaNet small разница
меньше: 0,119 против 0,110. Все числа опубликованы на одной странице
[официальных примеров и
замеров](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/models.html).
Trade-off: это минимальное архитектурное изменение, но INT8 меняет границы
интервалов даже в официальном примере. Перед заменой нужно повторить на трёх
контрольных записях число кластеров, purity, малые кластеры, неназначенные слова
и полный runtime. На текущем минутном профиле segmentation занимает лишь около
19% времени, поэтому одной квантизацией нельзя ожидать кратного ускорения всей
диаризации.
### 3D-Speaker CAMPPlus zh/en
Модель уже входит в официальный релиз speaker-recognition моделей sherpa-onnx,
а 3D-Speaker публикует CAM++ как штатную архитектуру своего набора
([репозиторий и таблица
моделей](https://github.com/modelscope/3D-Speaker)). Её ONNX-файл занимает
28,3 МБ — немного больше текущих 26,5 МБ, поэтому размер файла здесь плохо
предсказывает вычислительную стоимость.
Локально CAMPPlus дала RTF 0,083 против 0,118 у WeSpeaker, то есть была примерно
на 30% быстрее. При известном числе участников она также улучшила proxy-purity
на двух разговорах. Но в автоматическом режиме не нашлось единого порога: один
порог оставлял лишние кластеры, следующий уже склеивал реальные голоса. Полные
данные находятся в [отчёте о
калибровке](../benchmarks/2026-08-14-diarization-calibration.md).
Trade-off: хороший кандидат для режима с обязательным `--speakers N`, но не
готовая замена общего автоматического режима.
### NeMo TitaNet small
Sherpa-onnx официально показывает `nemo_en_titanet_small` как совместимый
embedding extractor. На его контрольной записи Pyannote FP32 + TitaNet small
дала RTF 0,119 вместо 0,297 у Pyannote FP32 + 3D-Speaker ERes2Net; с INT8
сегментацией — 0,110 вместо 0,241. Это самый большой опубликованный upstream
выигрыш среди проверенных на одной странице комбинаций
([официальные замеры](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/models.html)).
TitaNet использует 1D depth-wise separable convolutions и channel-attention
statistics pooling
([документация NVIDIA](https://docs.nvidia.com/nemo-framework/user-guide/25.02/nemotoolkit/asr/speaker_recognition/models.html)).
Trade-off: upstream-цифры сняты на другой embedding-модели сравнения, другом
материале и оборудовании, поэтому коэффициент нельзя переносить на этот ноутбук.
Кроме того, TitaNet small не проходила локальную калибровку на русской речи:
неизвестны подходящий clustering threshold, стабильность числа голосов и
качество коротких реплик. Сначала нужен тот же свип, который уже выполнен для
WeSpeaker и CAMPPlus.
### Увеличение шага окна
В C++-конфигурации sherpa-onnx 1.13.5 есть `window_shift_ratio`, поэтому на уровне
движка можно уменьшить число перекрывающихся окон ценой более грубой разметки.
Но [Python binding этой
версии](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/python/csrc/offline-speaker-diarization.cc)
экспортирует только путь `model`, а не `window_shift_ratio`. Для текущего Python
приложения это не штатная ручка без изменения upstream binding или собственного
нативного слоя. Даже после появления ручки потребуется отдельная калибровка:
более редкие окна могут ухудшить границы смены голоса и короткие ответы — как раз
самую чувствительную часть текущего результата.
## Практические следующие шаги
1. Не считать текущую стоимость дефектом реализации: выбранный результат
соответствует устройству sherpa-onnx и остаётся быстрее realtime даже на
старом Intel.
2. Первым отдельным экспериментом прогнать INT8 Pyannote на тех же трёх файлах.
Это наименьший по масштабу вариант, хотя ожидаемый выигрыш умеренный.
3. Отдельно откалибровать TitaNet small. У неё лучший опубликованный потенциал
скорости, но пока нет локальных данных о качестве.
4. CAMPPlus предлагать только как кандидат для режима с известным числом
участников, если 30% экономии оправдывает второй production-профиль.
5. Параллельный ASR и diarization исследовать независимо от выбора модели. Он не
уменьшает CPU-работу, но может сократить wall-clock latency. Обязательно
измерить конкуренцию за ядра и общий peak RSS: на Intel последовательные пики
составляли 900–1035 МБ для ASR и 366–469 МБ для диаризации, а совместный пик
пока не измерен.
## Вердикт
Текущая диаризация тяжёлая **в основном из-за каскада и плотного перекрытия
окон**, а не потому, что случайно выбрана гигантская модель. Но выбранный
WeSpeaker ResNet34 LM не самый быстрый extractor. Реалистичный резерв — умеренное
ускорение через INT8 segmentation, около 30% по локальным данным через CAMPPlus
при известном числе участников и потенциально более крупное ускорение через
TitaNet small после обязательной русскоязычной калибровки. Простое добавление
потоков или настройка clustering заметного выигрыша не даст.
+2
View File
@@ -8,7 +8,9 @@ dependencies = [
"typer>=0.24.1,<1", "typer>=0.24.1,<1",
"rich>=14.3.3,<15", "rich>=14.3.3,<15",
"faster-whisper>=1.2.1,<2", "faster-whisper>=1.2.1,<2",
"httpx>=0.28,<1",
"socksio>=1.0.0,<2", "socksio>=1.0.0,<2",
"sherpa-onnx>=1.13.5,<2",
"nvidia-cublas-cu12>=12.4,<13; sys_platform == 'linux' and platform_machine == 'x86_64'", "nvidia-cublas-cu12>=12.4,<13; sys_platform == 'linux' and platform_machine == 'x86_64'",
"openvino-genai>=2026.3.0.0,<2026.4; sys_platform != 'darwin' and (platform_machine == 'x86_64' or platform_machine == 'AMD64')", "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", "onnx-asr[cpu,hub]>=0.12,<0.13",
+5
View File
@@ -16,6 +16,11 @@ class Backend(Protocol):
наследование не требуется. наследование не требуется.
""" """
@property
def word_timestamps_available(self) -> bool:
"""Гарантирует ли выбранный backend/model пословные таймкоды."""
...
def ensure_model_available( def ensure_model_available(
self, self,
model_name: str, model_name: str,
@@ -2,9 +2,6 @@
from __future__ import annotations from __future__ import annotations
import gc
import io
import warnings
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -18,7 +15,12 @@ from faster_whisper import WhisperModel # noqa: E402
from huggingface_hub import snapshot_download # noqa: E402 from huggingface_hub import snapshot_download # noqa: E402
from huggingface_hub.errors import LocalEntryNotFoundError # noqa: E402 from huggingface_hub.errors import LocalEntryNotFoundError # noqa: E402
from local_transcriber.types import Segment, TranscribeResult # noqa: E402 from local_transcriber.types import ( # noqa: E402
Segment,
TranscribeResult,
Word,
WordTimestampsUnavailableError,
)
MODEL_REPOS = { MODEL_REPOS = {
"tiny": "Systran/faster-whisper-tiny", "tiny": "Systran/faster-whisper-tiny",
@@ -46,6 +48,8 @@ MODEL_REQUIRED_FILES = [
class FasterWhisperBackend: class FasterWhisperBackend:
"""Бэкенд транскрипции через faster-whisper (CTranslate2).""" """Бэкенд транскрипции через faster-whisper (CTranslate2)."""
word_timestamps_available = True
def __init__(self): def __init__(self):
self.actual_compute_type: str | None = None self.actual_compute_type: str | None = None
@@ -92,7 +96,9 @@ class FasterWhisperBackend:
""" """
try: try:
return WhisperModel( return WhisperModel(
model_path, device=device, compute_type=compute_type, model_path,
device=device,
compute_type=compute_type,
cpu_threads=cpu_threads, cpu_threads=cpu_threads,
) )
except ImportError as exc: except ImportError as exc:
@@ -114,12 +120,25 @@ class FasterWhisperBackend:
) -> TranscribeResult: ) -> TranscribeResult:
"""Транскрибирует файл через faster-whisper.""" """Транскрибирует файл через faster-whisper."""
segment_generator, info = model.transcribe( segment_generator, info = model.transcribe(
str(file_path), language=language, str(file_path),
language=language,
word_timestamps=True,
) )
total_duration = info.duration total_duration = info.duration
segments: list[Segment] = [] segments: list[Segment] = []
words: list[Word] = []
for raw_seg in segment_generator: for raw_seg in segment_generator:
seg = Segment(start=raw_seg.start, end=raw_seg.end, text=raw_seg.text) seg = Segment(start=raw_seg.start, end=raw_seg.end, text=raw_seg.text)
raw_words = raw_seg.words or []
if seg.text.strip() and not raw_words:
raise WordTimestampsUnavailableError(
"FasterWhisper не вернул пословные таймкоды "
"для распознанного сегмента"
)
words.extend(
Word(start=raw_word.start, end=raw_word.end, text=raw_word.word)
for raw_word in raw_words
)
if on_segment is not None: if on_segment is not None:
on_segment(seg) on_segment(seg)
segments.append(seg) segments.append(seg)
@@ -135,6 +154,7 @@ class FasterWhisperBackend:
language_probability=info.language_probability, language_probability=info.language_probability,
duration=info.duration, duration=info.duration,
device_used="", # оркестратор проставит actual_device device_used="", # оркестратор проставит actual_device
words=words,
) )
@@ -155,7 +175,9 @@ def _resolve_model_repo(model_name: str) -> str:
repo_id = MODEL_REPOS.get(model_name) repo_id = MODEL_REPOS.get(model_name)
if repo_id is None: if repo_id is None:
expected = ", ".join(MODEL_REPOS) expected = ", ".join(MODEL_REPOS)
raise ValueError(f"Неподдерживаемая модель '{model_name}'. Ожидалось одно из: {expected}") raise ValueError(
f"Неподдерживаемая модель '{model_name}'. Ожидалось одно из: {expected}"
)
return repo_id return repo_id
@@ -178,13 +200,17 @@ def _snapshot_download(repo_id: str, local_files_only: bool) -> str:
def _validate_model_dir(model_dir: Path) -> None: def _validate_model_dir(model_dir: Path) -> None:
missing = [ missing = [
filename for filename in MODEL_REQUIRED_FILES if not (model_dir / filename).exists() filename
for filename in MODEL_REQUIRED_FILES
if not (model_dir / filename).exists()
] ]
if not any(model_dir.glob("vocabulary.*")): if not any(model_dir.glob("vocabulary.*")):
missing.append("vocabulary.*") missing.append("vocabulary.*")
if missing: if missing:
missing_str = ", ".join(missing) missing_str = ", ".join(missing)
raise ValueError(f"Неполная локальная модель в '{model_dir}': отсутствуют {missing_str}") raise ValueError(
f"Неполная локальная модель в '{model_dir}': отсутствуют {missing_str}"
)
def _is_missing_socksio_error(exc: BaseException) -> bool: def _is_missing_socksio_error(exc: BaseException) -> bool:
+69 -5
View File
@@ -8,7 +8,13 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from local_transcriber.types import UNKNOWN_LANGUAGE, Segment, TranscribeResult from local_transcriber.types import (
UNKNOWN_LANGUAGE,
Segment,
TranscribeResult,
Word,
WordTimestampsUnavailableError,
)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -60,9 +66,7 @@ _WHISPER_MODEL_NAMES = frozenset(
_OPENVINO_ONLY_WHISPER_MODELS = frozenset({"large-v3-turbo"}) _OPENVINO_ONLY_WHISPER_MODELS = frozenset({"large-v3-turbo"})
MODEL_CATALOG: dict[str, OnnxModelSpec] = { MODEL_CATALOG: dict[str, OnnxModelSpec] = {
"gigaam-v3": OnnxModelSpec( "gigaam-v3": OnnxModelSpec("gigaam-v3-ctc", _INT8_AND_FLOAT32, _RUSSIAN_ONLY),
"gigaam-v3-ctc", _INT8_AND_FLOAT32, _RUSSIAN_ONLY
),
"parakeet-v3": OnnxModelSpec( "parakeet-v3": OnnxModelSpec(
"nemo-parakeet-tdt-0.6b-v3", "nemo-parakeet-tdt-0.6b-v3",
_INT8_AND_FLOAT32, _INT8_AND_FLOAT32,
@@ -135,6 +139,11 @@ class OnnxAsrBackend:
self._model_spec: OnnxModelSpec | None = None self._model_spec: OnnxModelSpec | None = None
self._vad: Any = None self._vad: Any = None
@property
def word_timestamps_available(self) -> bool:
"""Каталожные модели проверены; произвольный raw id отклоняется."""
return self._model_spec is not None
def ensure_model_available( def ensure_model_available(
self, self,
model_name: str, model_name: str,
@@ -194,7 +203,7 @@ class OnnxAsrBackend:
) )
vad = onnx_asr.load_vad("silero") vad = onnx_asr.load_vad("silero")
self._vad = vad self._vad = vad
return model.with_vad(vad) return model.with_vad(vad).with_timestamps()
def transcribe( def transcribe(
self, self,
@@ -215,10 +224,13 @@ class OnnxAsrBackend:
self._warn_if_language_unsupported(language) self._warn_if_language_unsupported(language)
_notify(on_status, "Загружаю аудио...") _notify(on_status, "Загружаю аудио...")
audio_array = decode_audio(str(file_path), sampling_rate=16000) audio_array = decode_audio(str(file_path), sampling_rate=16000)
if isinstance(audio_array, tuple):
raise TypeError("Декодер неожиданно вернул раздельные стереоканалы")
duration = len(audio_array) / 16000.0 duration = len(audio_array) / 16000.0
_notify(on_status, "Транскрибирую (onnx-asr)...") _notify(on_status, "Транскрибирую (onnx-asr)...")
segments: list[Segment] = [] segments: list[Segment] = []
words: list[Word] = []
result_language = ( result_language = (
language or _model_language(self._model_spec) or UNKNOWN_LANGUAGE language or _model_language(self._model_spec) or UNKNOWN_LANGUAGE
) )
@@ -235,6 +247,12 @@ class OnnxAsrBackend:
end=end, end=end,
text=vad_seg.text, text=vad_seg.text,
) )
segment_words = _timestamped_segment_words(vad_seg, start, end)
if vad_seg.text.strip() and not segment_words:
raise WordTimestampsUnavailableError(
"ONNX-ASR не вернул пословные таймкоды для распознанного текста"
)
words.extend(segment_words)
if on_segment is not None: if on_segment is not None:
on_segment(seg) on_segment(seg)
segments.append(seg) segments.append(seg)
@@ -249,6 +267,7 @@ class OnnxAsrBackend:
language_probability=1.0 if language else 0.0, language_probability=1.0 if language else 0.0,
duration=duration, duration=duration,
device_used="", # оркестратор проставит device_used="", # оркестратор проставит
words=words,
) )
def _resolve_model(self, model_name: str) -> str: def _resolve_model(self, model_name: str) -> str:
@@ -326,3 +345,48 @@ def _model_language(spec: OnnxModelSpec | None) -> str | None:
def _notify(on_status: Callable[[str], None] | None, message: str) -> None: def _notify(on_status: Callable[[str], None] | None, message: str) -> None:
if on_status is not None: if on_status is not None:
on_status(message) on_status(message)
def _timestamped_segment_words(
vad_segment: Any,
segment_start: float,
segment_end: float,
) -> list[Word]:
tokens = getattr(vad_segment, "tokens", None)
timestamps = getattr(vad_segment, "timestamps", None)
if not tokens or not timestamps or len(tokens) != len(timestamps):
return []
grouped: list[tuple[float, str]] = []
current_start = float(timestamps[0])
current_tokens: list[str] = []
for token, timestamp in zip(tokens, timestamps, strict=True):
if token[:1].isspace() and current_tokens:
grouped.append((current_start, "".join(current_tokens)))
current_start = float(timestamp)
current_tokens = []
current_tokens.append(token)
grouped.append((current_start, "".join(current_tokens)))
words: list[Word] = []
for index, (relative_start, text) in enumerate(grouped):
start = min(
segment_end,
max(segment_start, segment_start + relative_start),
)
next_start = next(
(
candidate_start
for candidate_start, _ in grouped[index + 1 :]
if candidate_start > relative_start
),
None,
)
end = max(
start,
min(segment_end, segment_start + next_start)
if next_start is not None
else segment_end,
)
words.append(Word(start=start, end=end, text=text))
return words
+55 -8
View File
@@ -2,9 +2,9 @@
from __future__ import annotations from __future__ import annotations
import json
import threading import threading
import time import time
import warnings
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -12,7 +12,13 @@ from typing import Any
from huggingface_hub import snapshot_download from huggingface_hub import snapshot_download
from huggingface_hub.errors import LocalEntryNotFoundError from huggingface_hub.errors import LocalEntryNotFoundError
from local_transcriber.types import UNKNOWN_LANGUAGE, Segment, TranscribeResult from local_transcriber.types import (
UNKNOWN_LANGUAGE,
Segment,
TranscribeResult,
Word,
WordTimestampsUnavailableError,
)
# (model_alias, compute_type) → HF repo # (model_alias, compute_type) → HF repo
MODEL_REPOS: dict[tuple[str, str], str] = { MODEL_REPOS: dict[tuple[str, str], str] = {
@@ -43,12 +49,15 @@ _IMPLICIT_COMPUTE_TYPE_OVERRIDES: dict[str, str] = {
MODEL_REQUIRED_FILES = [ MODEL_REQUIRED_FILES = [
"openvino_encoder_model.xml", "openvino_encoder_model.xml",
"openvino_decoder_model.xml", "openvino_decoder_model.xml",
"generation_config.json",
] ]
class OpenVINOBackend: class OpenVINOBackend:
"""Бэкенд транскрипции через openvino-genai WhisperPipeline.""" """Бэкенд транскрипции через openvino-genai WhisperPipeline."""
word_timestamps_available = True
def __init__( def __init__(
self, self,
ov_device: str = "openvino-cpu", ov_device: str = "openvino-cpu",
@@ -82,7 +91,9 @@ class OpenVINOBackend:
except ValueError: except ValueError:
_notify(on_status, f"Кэш модели {model_name} неполный, докачиваю...") _notify(on_status, f"Кэш модели {model_name} неполный, докачиваю...")
_notify(on_status, f"Скачиваю модель {model_name} (OpenVINO) из Hugging Face...") _notify(
on_status, f"Скачиваю модель {model_name} (OpenVINO) из Hugging Face..."
)
downloaded_path = Path(snapshot_download(repo_id, local_files_only=False)) downloaded_path = Path(snapshot_download(repo_id, local_files_only=False))
_validate_model_dir(downloaded_path) _validate_model_dir(downloaded_path)
return str(downloaded_path) return str(downloaded_path)
@@ -115,7 +126,11 @@ class OpenVINOBackend:
ov_dev = self._resolve_ov_device() ov_dev = self._resolve_ov_device()
self.actual_ov_device = ov_dev self.actual_ov_device = ov_dev
return ov_genai.WhisperPipeline(model_path, ov_dev) return ov_genai.WhisperPipeline(
model_path,
ov_dev,
word_timestamps=True,
)
def transcribe( def transcribe(
self, self,
@@ -130,16 +145,23 @@ class OpenVINOBackend:
_notify(on_status, "Загружаю аудио...") _notify(on_status, "Загружаю аудио...")
raw_speech = decode_audio(str(file_path), sampling_rate=16000) raw_speech = decode_audio(str(file_path), sampling_rate=16000)
if isinstance(raw_speech, tuple):
raise TypeError("Декодер неожиданно вернул раздельные стереоканалы")
duration = len(raw_speech) / 16000.0 duration = len(raw_speech) / 16000.0
kwargs: dict[str, Any] = {"return_timestamps": True} kwargs: dict[str, Any] = {
"return_timestamps": True,
"word_timestamps": True,
}
if language: if language:
kwargs["language"] = f"<|{language}|>" kwargs["language"] = f"<|{language}|>"
dur_min = int(duration // 60) dur_min = int(duration // 60)
duration_str = f"{dur_min} мин" if dur_min > 0 else f"{int(duration)} сек" duration_str = f"{dur_min} мин" if dur_min > 0 else f"{int(duration)} сек"
pcm_list = raw_speech.tolist() pcm_list = raw_speech.tolist()
result = _generate_with_progress(model, pcm_list, kwargs, duration_str, on_status) result = _generate_with_progress(
model, pcm_list, kwargs, duration_str, on_status
)
segments: list[Segment] = [] segments: list[Segment] = []
if hasattr(result, "chunks") and result.chunks: if hasattr(result, "chunks") and result.chunks:
@@ -159,6 +181,16 @@ class OpenVINOBackend:
f"Транскрибирую (OpenVINO)... [{len(segments)} сегм.]", f"Транскрибирую (OpenVINO)... [{len(segments)} сегм.]",
) )
words = []
for raw_word in getattr(result, "words", None) or []:
start = min(duration, max(0.0, raw_word.start_ts))
end = min(duration, max(start, raw_word.end_ts))
words.append(Word(start=start, end=end, text=raw_word.word))
if any(segment.text.strip() for segment in segments) and not words:
raise WordTimestampsUnavailableError(
"OpenVINO не вернул пословные таймкоды для распознанного текста"
)
detected_language = language or UNKNOWN_LANGUAGE detected_language = language or UNKNOWN_LANGUAGE
language_probability = 1.0 if language else 0.0 language_probability = 1.0 if language else 0.0
@@ -168,6 +200,7 @@ class OpenVINOBackend:
language_probability=language_probability, language_probability=language_probability,
duration=duration, duration=duration,
device_used="", # оркестратор проставит device_used="", # оркестратор проставит
words=words,
) )
def _resolve_repo(self, model_name: str, compute_type: str) -> tuple[str, str]: def _resolve_repo(self, model_name: str, compute_type: str) -> tuple[str, str]:
@@ -176,7 +209,10 @@ class OpenVINOBackend:
Возвращает (repo_id, actual_compute_type). Возвращает (repo_id, actual_compute_type).
""" """
# Для неявного compute_type: override для конкретных моделей # Для неявного compute_type: override для конкретных моделей
if not self._compute_type_explicit and model_name in _IMPLICIT_COMPUTE_TYPE_OVERRIDES: if (
not self._compute_type_explicit
and model_name in _IMPLICIT_COMPUTE_TYPE_OVERRIDES
):
compute_type = _IMPLICIT_COMPUTE_TYPE_OVERRIDES[model_name] compute_type = _IMPLICIT_COMPUTE_TYPE_OVERRIDES[model_name]
# Точное совпадение # Точное совпадение
@@ -231,7 +267,10 @@ def _generate_with_progress(
while thread.is_alive(): while thread.is_alive():
elapsed = int(time.monotonic() - start) elapsed = int(time.monotonic() - start)
elapsed_str = f"{elapsed // 60:02d}:{elapsed % 60:02d}" elapsed_str = f"{elapsed // 60:02d}:{elapsed % 60:02d}"
_notify(on_status, f"Транскрибирую {duration_str} аудио (OpenVINO)... прошло {elapsed_str}") _notify(
on_status,
f"Транскрибирую {duration_str} аудио (OpenVINO)... прошло {elapsed_str}",
)
thread.join(timeout=1.0) thread.join(timeout=1.0)
if error_box[0] is not None: if error_box[0] is not None:
@@ -251,3 +290,11 @@ def _validate_model_dir(model_dir: Path) -> None:
raise ValueError( raise ValueError(
f"Неполная OpenVINO модель в '{model_dir}': отсутствуют {', '.join(missing)}" f"Неполная OpenVINO модель в '{model_dir}': отсутствуют {', '.join(missing)}"
) )
generation_config = json.loads(
(model_dir / "generation_config.json").read_text(encoding="utf-8")
)
if not generation_config.get("alignment_heads"):
raise ValueError(
f"OpenVINO модель в '{model_dir}' не содержит alignment_heads "
"для пословных таймкодов"
)
+276 -47
View File
@@ -11,6 +11,7 @@ from rich.status import Status
from .config import apply_device_defaults, load_config, resolve_defaults from .config import apply_device_defaults, load_config, resolve_defaults
from .context_menu import install_menu as install_context_menu from .context_menu import install_menu as install_context_menu
from .context_menu import uninstall_menu as uninstall_context_menu from .context_menu import uninstall_menu as uninstall_context_menu
from .diarization import build_speaker_transcript
from .formatter import ( from .formatter import (
LANGUAGE_DETECTED, LANGUAGE_DETECTED,
LANGUAGE_FORCED, LANGUAGE_FORCED,
@@ -27,6 +28,7 @@ from .quality import (
find_repetition_blocks, find_repetition_blocks,
tail_gap, tail_gap,
) )
from .speaker_diarizer import SpeakerDiarizer, load_speaker_diarizer
from .transcriber import ( from .transcriber import (
Segment, Segment,
TranscribeResult, TranscribeResult,
@@ -34,7 +36,12 @@ from .transcriber import (
_transcribe_file, _transcribe_file,
load_model, load_model,
) )
from .types import UNKNOWN_LANGUAGE from .types import (
UNKNOWN_LANGUAGE,
DiarizationRun,
SpeakerTranscript,
StatusCallback,
)
from .utils import ( from .utils import (
build_output_path, build_output_path,
detect_device, detect_device,
@@ -62,9 +69,7 @@ def _format_device_info(device_used: str) -> str:
return "CPU" return "CPU"
def _format_language_mode( def _format_language_mode(requested_language: str, result: TranscribeResult) -> str:
requested_language: str, result: TranscribeResult
) -> str:
"""Описывает источник языка, не выдавая профиль модели за детектор.""" """Описывает источник языка, не выдавая профиль модели за детектор."""
if requested_language != "auto": if requested_language != "auto":
return LANGUAGE_FORCED return LANGUAGE_FORCED
@@ -92,7 +97,9 @@ def _format_repetition_blocks(
return summary return summary
def _print_quality_warnings(result: TranscribeResult, file_name: str | None = None) -> None: def _print_quality_warnings(
result: TranscribeResult, file_name: str | None = None
) -> None:
"""Печатает предупреждения о возможной потере содержания.""" """Печатает предупреждения о возможной потере содержания."""
is_batch = file_name is not None is_batch = file_name is not None
use_hours = result.duration > 3600 use_hours = result.duration > 3600
@@ -102,8 +109,7 @@ def _print_quality_warnings(result: TranscribeResult, file_name: str | None = No
covered = format_duration(result.segments[-1].end) covered = format_duration(result.segments[-1].end)
total = format_duration(result.duration) total = format_duration(result.duration)
message = ( message = (
f"транскрипт покрывает {covered} из {total}" f"транскрипт покрывает {covered} из {total}возможна потеря хвоста записи"
"возможна потеря хвоста записи"
) )
if is_batch: if is_batch:
console.print(f" {file_name}: {message}", style="yellow") console.print(f" {file_name}: {message}", style="yellow")
@@ -128,6 +134,64 @@ def _print_quality_warnings(result: TranscribeResult, file_name: str | None = No
) )
def _diarize_result(
file_path: Path,
result: TranscribeResult,
diarizer: SpeakerDiarizer,
on_status: StatusCallback,
) -> tuple[SpeakerTranscript | None, str | None, DiarizationRun | None]:
"""Запускает диаризацию и переводит ожидаемые сбои в деградацию вывода."""
try:
run = diarizer.process(file_path, on_status=on_status)
transcript = build_speaker_transcript(
result.words,
run.intervals,
result.duration,
)
if not run.intervals:
warning = "Диаризатор не нашёл интервалов при непустом распознавании"
elif transcript.cluster_count < 2:
warning = "Найден только один голосовой кластер"
else:
warning = None
return transcript, warning, run
except Exception as exc:
return None, f"Диаризация завершилась с ошибкой: {exc}", None
def _print_diarization_report(
transcript: SpeakerTranscript,
run: DiarizationRun,
verbose: bool,
file_name: str | None = None,
) -> None:
"""Печатает метрики verbose и обязательные предупреждения сведения."""
if verbose:
indent = " " if file_name is not None else ""
console.print(
f"{indent}Диаризация: {transcript.cluster_count} кластеров, "
f"{len(run.intervals)} интервалов, {run.elapsed_seconds:.1f} с"
)
warning_prefix = f" {file_name}: " if file_name is not None else "Внимание: "
if transcript.unassigned_word_count:
console.print(
f"{warning_prefix}{transcript.unassigned_word_count} слов "
"без назначенного говорящего",
style="yellow",
)
for cluster in transcript.small_clusters:
label = (
f"Speaker {cluster.speaker}"
if cluster.speaker is not None
else "кластер без номера"
)
console.print(
f"{warning_prefix}малый кластер {label}: {cluster.duration:.1f} с",
style="yellow",
)
@app.command() @app.command()
def main( def main(
files: list[Path] | None = typer.Argument(None, help="Пути к аудио/видеофайлам"), files: list[Path] | None = typer.Argument(None, help="Пути к аудио/видеофайлам"),
@@ -141,26 +205,54 @@ def main(
language: str | None = typer.Option( language: str | None = typer.Option(
None, "--language", "-l", show_default=False, help="Язык [по умолч.: ru]" None, "--language", "-l", show_default=False, help="Язык [по умолч.: ru]"
), ),
output: Path | None = typer.Option(None, "--output", "-o", help="Путь к выходному файлу"), output: Path | None = typer.Option(
None, "--output", "-o", help="Путь к выходному файлу"
),
device: str | None = typer.Option( device: str | None = typer.Option(
None, "--device", "-d", show_default=False, None,
help="Устройство (auto|cpu|cuda|openvino|openvino-gpu|openvino-cpu|onnx) [по умолч.: auto]" "--device",
"-d",
show_default=False,
help="Устройство (auto|cpu|cuda|openvino|openvino-gpu|openvino-cpu|onnx) [по умолч.: auto]",
), ),
compute_type: str | None = typer.Option( compute_type: str | None = typer.Option(
None, "--compute-type", show_default=False, None,
"--compute-type",
show_default=False,
help=( help=(
"Тип вычислений [по умолч.: float16 (CUDA) / " "Тип вычислений [по умолч.: float16 (CUDA) / "
"int8 (ONNX/OpenVINO) / float32 (CPU)]" "int8 (ONNX/OpenVINO) / float32 (CPU)]"
), ),
), ),
threads: int = typer.Option( threads: int = typer.Option(
0, "--threads", "-t", show_default=False, min=0, 0,
help="Потоки CPU (0 = дефолт библиотеки; рекомендуется = число физ. ядер)" "--threads",
"-t",
show_default=False,
min=0,
help="Потоки CPU (0 = дефолт библиотеки; рекомендуется = число физ. ядер)",
),
diarize: bool = typer.Option(
False,
"--diarize",
help="Разделить транскрипт на реплики говорящих",
),
speakers: int | None = typer.Option(
None,
"--speakers",
min=1,
help="Известное число говорящих; автоматически включает --diarize",
), ),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Подробный вывод"), verbose: bool = typer.Option(False, "--verbose", "-v", help="Подробный вывод"),
force: bool = typer.Option(False, "--force", "-f", help="Перезаписать существующие транскрипты"), force: bool = typer.Option(
install_menu: bool = typer.Option(False, "--install-menu", help="Установить пункт Transcribe в SendTo"), False, "--force", "-f", help="Перезаписать существующие транскрипты"
uninstall_menu: bool = typer.Option(False, "--uninstall-menu", help="Удалить пункт Transcribe из SendTo"), ),
install_menu: bool = typer.Option(
False, "--install-menu", help="Установить пункт Transcribe в SendTo"
),
uninstall_menu: bool = typer.Option(
False, "--uninstall-menu", help="Удалить пункт Transcribe из SendTo"
),
) -> None: ) -> None:
"""Транскрибирует аудио/видеофайлы в markdown с таймкодами. """Транскрибирует аудио/видеофайлы в markdown с таймкодами.
@@ -170,25 +262,31 @@ def main(
if install_menu or uninstall_menu: if install_menu or uninstall_menu:
if install_menu and uninstall_menu: if install_menu and uninstall_menu:
console.print("--install-menu и --uninstall-menu несовместимы.", style="red bold") console.print(
"--install-menu и --uninstall-menu несовместимы.", style="red bold"
)
raise SystemExit(2) raise SystemExit(2)
if files: if files:
console.print("Флаги меню нельзя использовать вместе с файлами.", style="red bold") console.print(
"Флаги меню нельзя использовать вместе с файлами.", style="red bold"
)
raise SystemExit(2) raise SystemExit(2)
if sys.platform != "win32": if sys.platform != "win32":
console.print("Пункт меню SendTo доступен только на Windows.", style="red bold") console.print(
"Пункт меню SendTo доступен только на Windows.", style="red bold"
)
raise SystemExit(1) raise SystemExit(1)
try: try:
if install_menu: if install_menu:
cmd_path = install_context_menu() cmd_path = install_context_menu()
console.print(f"Пункт меню установлен: \"{cmd_path}\"", style="green") console.print(f'Пункт меню установлен: "{cmd_path}"', style="green")
else: else:
cmd_path = uninstall_context_menu() cmd_path = uninstall_context_menu()
if cmd_path is None: if cmd_path is None:
console.print("Пункт меню не был установлен.", style="yellow") console.print("Пункт меню не был установлен.", style="yellow")
else: else:
console.print(f"Пункт меню удалён: \"{cmd_path}\"", style="green") console.print(f'Пункт меню удалён: "{cmd_path}"', style="green")
except RuntimeError as exc: except RuntimeError as exc:
console.print(f"Ошибка: {exc}", style="red bold") console.print(f"Ошибка: {exc}", style="red bold")
raise SystemExit(1) raise SystemExit(1)
@@ -203,7 +301,12 @@ def main(
try: try:
config = load_config() config = load_config()
cli_values = {"model": model, "language": language, "device": device, "compute_type": compute_type} cli_values = {
"model": model,
"language": language,
"device": device,
"compute_type": compute_type,
}
defaults = resolve_defaults(cli_values, config) defaults = resolve_defaults(cli_values, config)
resolved_device = detect_device(defaults["device"]) resolved_device = detect_device(defaults["device"])
@@ -218,13 +321,33 @@ def main(
is_batch = len(expanded) > 1 is_batch = len(expanded) > 1
if is_batch and output is not None: if is_batch and output is not None:
console.print("--output несовместим с несколькими файлами.", style="red bold") console.print(
"--output несовместим с несколькими файлами.", style="red bold"
)
raise SystemExit(1) raise SystemExit(1)
if is_batch: if is_batch:
_run_batch(expanded, defaults, verbose, force, ct_explicit, cpu_threads=threads) _run_batch(
expanded,
defaults,
verbose,
force,
ct_explicit,
cpu_threads=threads,
diarize=diarize or speakers is not None,
speakers=speakers,
)
else: else:
_run_single(expanded[0], defaults, output, verbose, ct_explicit, cpu_threads=threads) _run_single(
expanded[0],
defaults,
output,
verbose,
ct_explicit,
cpu_threads=threads,
diarize=diarize or speakers is not None,
speakers=speakers,
)
except KeyboardInterrupt: except KeyboardInterrupt:
console.print("\nПрервано пользователем.", style="yellow") console.print("\nПрервано пользователем.", style="yellow")
raise SystemExit(130) raise SystemExit(130)
@@ -250,9 +373,7 @@ def main(
console.print_exception() console.print_exception()
else: else:
console.print(f"Ошибка: {exc}", style="red bold") console.print(f"Ошибка: {exc}", style="red bold")
console.print( console.print("Запустите с --verbose для полного traceback.", style="dim")
"Запустите с --verbose для полного traceback.", style="dim"
)
raise SystemExit(1) raise SystemExit(1)
@@ -263,6 +384,8 @@ def _run_single(
verbose: bool, verbose: bool,
compute_type_explicit: bool = False, compute_type_explicit: bool = False,
cpu_threads: int = 0, cpu_threads: int = 0,
diarize: bool = False,
speakers: int | None = None,
) -> None: ) -> None:
"""Пайплайн одного файла: валидация → модель → транскрипция → запись.""" """Пайплайн одного файла: валидация → модель → транскрипция → запись."""
start = time.monotonic() start = time.monotonic()
@@ -279,12 +402,18 @@ def _run_single(
console.print(f" [{seg.start:.2f}s] {seg.text.strip()}") console.print(f" [{seg.start:.2f}s] {seg.text.strip()}")
model_obj, actual_device, backend, model_path = load_model( model_obj, actual_device, backend, model_path = load_model(
defaults["model"], resolved_device, defaults["compute_type"], defaults["model"],
on_status=lambda msg: console.print(msg), strict_device=strict, resolved_device,
defaults["compute_type"],
on_status=lambda msg: console.print(msg),
strict_device=strict,
compute_type_explicit=compute_type_explicit, compute_type_explicit=compute_type_explicit,
cpu_threads=cpu_threads, cpu_threads=cpu_threads,
) )
actual_ct = getattr(backend, "actual_compute_type", defaults["compute_type"]) or defaults["compute_type"] actual_ct = (
getattr(backend, "actual_compute_type", defaults["compute_type"])
or defaults["compute_type"]
)
console.print( console.print(
f"Модель: [bold]{defaults['model']}[/bold] " f"Модель: [bold]{defaults['model']}[/bold] "
f"Устройство: [bold]{actual_device}[/bold] " f"Устройство: [bold]{actual_device}[/bold] "
@@ -296,6 +425,18 @@ def _run_single(
style="dim", 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),
)
with Status("Подготавливаю запуск...", console=console) as status: with Status("Подготавливаю запуск...", console=console) as status:
tfr = _transcribe_file( tfr = _transcribe_file(
model=model_obj, model=model_obj,
@@ -313,6 +454,31 @@ def _run_single(
) )
result = tfr.result result = tfr.result
speaker_transcript = None
diarization_warning = None
diarization_degraded = False
if speaker_diarizer is not None and result.segments:
with Status("Определяю говорящих...", console=console) as status:
speaker_transcript, diarization_warning, diarization_run = _diarize_result(
validated_file,
result,
speaker_diarizer,
on_status=(
(lambda message: console.print(message))
if verbose
else status.update
),
)
diarization_degraded = diarization_warning is not None
if diarization_run is not None and speaker_transcript is not None:
_print_diarization_report(
speaker_transcript,
diarization_run,
verbose,
)
if diarization_warning is not None:
console.print(f"Внимание: {diarization_warning}", style="yellow")
if tfr.actual_device != resolved_device: if tfr.actual_device != resolved_device:
if requested_device == "auto": if requested_device == "auto":
@@ -328,9 +494,10 @@ def _run_single(
) )
if len(result.segments) == 0: if len(result.segments) == 0:
console.print( message = f"Речь не обнаружена в файле {validated_file.name}"
f"Речь не обнаружена в файле {validated_file.name}", style="yellow" if speaker_diarizer is not None:
) message += "; диаризация не запускалась"
console.print(message, style="yellow")
device_info = _format_device_info(result.device_used) device_info = _format_device_info(result.device_used)
language_mode = _format_language_mode(defaults["language"], result) language_mode = _format_language_mode(defaults["language"], result)
@@ -341,13 +508,17 @@ def _run_single(
model_name=defaults["model"], model_name=defaults["model"],
device_info=device_info, device_info=device_info,
language_mode=language_mode, language_mode=language_mode,
speaker_transcript=speaker_transcript,
diarization_warning=diarization_warning,
) )
write_transcript(content, output_path) write_transcript(content, output_path)
elapsed = time.monotonic() - start elapsed = time.monotonic() - start
console.print(f"Транскрипт сохранён: \"{output_path}\"", style="green") console.print(f'Транскрипт сохранён: "{output_path}"', style="green")
console.print(f" Сегментов: {len(result.segments)} Время: {elapsed:.1f}с") console.print(f" Сегментов: {len(result.segments)} Время: {elapsed:.1f}с")
_print_quality_warnings(result) _print_quality_warnings(result)
if diarization_degraded:
raise SystemExit(1)
def _run_batch( def _run_batch(
@@ -357,6 +528,8 @@ def _run_batch(
force: bool, force: bool,
compute_type_explicit: bool = False, compute_type_explicit: bool = False,
cpu_threads: int = 0, cpu_threads: int = 0,
diarize: bool = False,
speakers: int | None = None,
) -> None: ) -> None:
"""Трёхфазный батч-пайплайн: prescan → загрузка модели → транскрипция.""" """Трёхфазный батч-пайплайн: prescan → загрузка модели → транскрипция."""
# Phase 1: Prescan — fail-fast + skip до загрузки модели (экономим ~2-5 сек) # Phase 1: Prescan — fail-fast + skip до загрузки модели (экономим ~2-5 сек)
@@ -379,9 +552,7 @@ def _run_batch(
to_process.append(validated) to_process.append(validated)
if not to_process: if not to_process:
console.print( console.print(f"\nИтого: 0 обработано, {skipped} пропущено, {invalid} ошибок")
f"\nИтого: 0 обработано, {skipped} пропущено, {invalid} ошибок"
)
if invalid > 0: if invalid > 0:
raise SystemExit(1) raise SystemExit(1)
return return
@@ -391,8 +562,11 @@ def _run_batch(
resolved_device = detect_device(requested_device) resolved_device = detect_device(requested_device)
strict = requested_device != "auto" strict = requested_device != "auto"
model_obj, actual_device, backend, model_path = load_model( model_obj, actual_device, backend, model_path = load_model(
defaults["model"], resolved_device, defaults["compute_type"], defaults["model"],
on_status=lambda msg: console.print(msg), strict_device=strict, resolved_device,
defaults["compute_type"],
on_status=lambda msg: console.print(msg),
strict_device=strict,
compute_type_explicit=compute_type_explicit, compute_type_explicit=compute_type_explicit,
cpu_threads=cpu_threads, cpu_threads=cpu_threads,
) )
@@ -416,8 +590,21 @@ def _run_batch(
style="yellow", 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 3: Transcribe # Phase 3: Transcribe
processed = 0 processed = 0
degraded = 0
failed = 0 failed = 0
batch_start = time.monotonic() batch_start = time.monotonic()
@@ -439,9 +626,13 @@ def _run_batch(
file_path=file, file_path=file,
model_name=defaults["model"], model_name=defaults["model"],
compute_type=defaults["compute_type"], compute_type=defaults["compute_type"],
language=defaults["language"] if defaults["language"] != "auto" else None, language=defaults["language"]
if defaults["language"] != "auto"
else None,
on_segment=on_segment if verbose else None, on_segment=on_segment if verbose else None,
on_status=status.update if not verbose else lambda msg: console.print(msg), on_status=status.update
if not verbose
else lambda msg: console.print(msg),
strict_device=strict, strict_device=strict,
cpu_threads=cpu_threads, cpu_threads=cpu_threads,
) )
@@ -459,11 +650,44 @@ def _run_batch(
result = tfr.result result = tfr.result
language_mode = _format_language_mode(defaults["language"], result) language_mode = _format_language_mode(defaults["language"], result)
speaker_transcript = None
diarization_warning = None
file_degraded = False
if speaker_diarizer is not None and result.segments:
with Status("Определяю говорящих...", console=console) as status:
speaker_transcript, diarization_warning, diarization_run = (
_diarize_result(
file,
result,
speaker_diarizer,
on_status=(
(lambda message: console.print(message))
if verbose
else status.update
),
)
)
file_degraded = diarization_warning is not None
if diarization_run is not None and speaker_transcript is not None:
_print_diarization_report(
speaker_transcript,
diarization_run,
verbose,
file_name=file.name,
)
if diarization_warning is not None:
console.print(
f" {file.name}: {diarization_warning}",
style="yellow",
)
if len(result.segments) == 0: if len(result.segments) == 0:
console.print( message = f" Речь не обнаружена: {file.name}"
f" Речь не обнаружена: {file.name}", style="yellow" if speaker_diarizer is not None:
) message += "; диаризация не запускалась"
console.print(message, style="yellow")
device_info = _format_device_info(result.device_used) device_info = _format_device_info(result.device_used)
@@ -473,6 +697,8 @@ def _run_batch(
model_name=defaults["model"], model_name=defaults["model"],
device_info=device_info, device_info=device_info,
language_mode=language_mode, language_mode=language_mode,
speaker_transcript=speaker_transcript,
diarization_warning=diarization_warning,
) )
write_transcript(content, build_output_path(file)) write_transcript(content, build_output_path(file))
file_elapsed = time.monotonic() - file_start file_elapsed = time.monotonic() - file_start
@@ -482,6 +708,8 @@ def _run_batch(
style="green", style="green",
) )
processed += 1 processed += 1
if file_degraded:
degraded += 1
_print_quality_warnings(result, file.name) _print_quality_warnings(result, file.name)
except KeyboardInterrupt: except KeyboardInterrupt:
raise raise
@@ -495,10 +723,11 @@ def _run_batch(
total_failed = invalid + failed total_failed = invalid + failed
batch_elapsed = time.monotonic() - batch_start batch_elapsed = time.monotonic() - batch_start
console.print( console.print(
f"\nИтого: {processed} обработано, {skipped} пропущено, {total_failed} ошибок" f"\nИтого: {processed} обработано, {skipped} пропущено, "
f"{degraded} с деградацией, {total_failed} ошибок"
f" Время: {batch_elapsed:.1f}с" f" Время: {batch_elapsed:.1f}с"
) )
if total_failed > 0: if total_failed > 0 or degraded > 0:
raise SystemExit(1) raise SystemExit(1)
+131
View File
@@ -0,0 +1,131 @@
"""Сведение слов с временной привязкой и разметки говорящих."""
from collections import defaultdict
from math import isclose
from unicodedata import category
from .types import (
SmallSpeakerCluster,
SpeakerInterval,
SpeakerTranscript,
SpeakerTurn,
Word,
)
_PAUSE_THRESHOLD_S = 2.0
_MAX_TURN_S = 60.0
def build_speaker_transcript(
words: list[Word],
intervals: list[SpeakerInterval],
recording_duration: float,
) -> SpeakerTranscript:
"""Назначает словам говорящих и собирает линейные реплики."""
cluster_numbers: dict[int, int] = {}
assigned: list[tuple[Word, int | None]] = []
unassigned = 0
for word in words:
speaker_cluster = _assign_cluster(word, intervals)
if speaker_cluster is None:
speaker = None
unassigned += 1
else:
speaker = cluster_numbers.setdefault(
speaker_cluster,
len(cluster_numbers) + 1,
)
assigned.append((word, speaker))
return SpeakerTranscript(
turns=_group_words(assigned),
cluster_count=len({interval.cluster for interval in intervals}),
unassigned_word_count=unassigned,
small_clusters=_find_small_clusters(
intervals,
cluster_numbers,
recording_duration,
),
)
def _assign_cluster(word: Word, intervals: list[SpeakerInterval]) -> int | None:
overlaps: defaultdict[int, float] = defaultdict(float)
for interval in intervals:
overlap = min(word.end, interval.end) - max(word.start, interval.start)
if overlap > 0:
overlaps[interval.cluster] += overlap
if not overlaps:
return None
largest = max(overlaps.values())
winners = [
cluster
for cluster, overlap in overlaps.items()
if isclose(overlap, largest, rel_tol=1e-9, abs_tol=1e-9)
]
return winners[0] if len(winners) == 1 else None
def _group_words(assigned: list[tuple[Word, int | None]]) -> list[SpeakerTurn]:
if not assigned:
return []
turns: list[SpeakerTurn] = []
first_word, current_speaker = assigned[0]
start = first_word.start
end = first_word.end
text = first_word.text
for word, speaker in assigned[1:]:
should_split = (
speaker != current_speaker
or word.start - end >= _PAUSE_THRESHOLD_S
or word.end - start > _MAX_TURN_S
)
if should_split:
turns.append(
SpeakerTurn(start, end, _normalize_turn_text(text), current_speaker)
)
start = word.start
text = word.text
current_speaker = speaker
else:
text = _append_word_text(text, word.text)
end = word.end
turns.append(SpeakerTurn(start, end, _normalize_turn_text(text), current_speaker))
return turns
def _append_word_text(current: str, word_text: str) -> str:
if not current or not word_text or word_text[:1].isspace():
return current + word_text
if category(word_text[0])[:1] in {"P", "S"}:
return current + word_text
return f"{current} {word_text}"
def _normalize_turn_text(text: str) -> str:
return " ".join(text.split())
def _find_small_clusters(
intervals: list[SpeakerInterval],
cluster_numbers: dict[int, int],
recording_duration: float,
) -> list[SmallSpeakerCluster]:
durations: defaultdict[int, float] = defaultdict(float)
for interval in intervals:
durations[interval.cluster] += max(0.0, interval.end - interval.start)
threshold = max(5.0, recording_duration * 0.02)
return [
SmallSpeakerCluster(
speaker=cluster_numbers.get(cluster),
duration=duration,
)
for cluster, duration in durations.items()
if duration < threshold
]
+39 -1
View File
@@ -5,7 +5,7 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from .quality import TAIL_GAP_WARN_S, find_repetition_blocks, tail_gap from .quality import TAIL_GAP_WARN_S, find_repetition_blocks, tail_gap
from .types import Segment, TranscribeResult from .types import Segment, SpeakerTranscript, TranscribeResult
_PAUSE_THRESHOLD_S = 2.0 # пауза между сегментами для разбиения на абзацы _PAUSE_THRESHOLD_S = 2.0 # пауза между сегментами для разбиения на абзацы
_MAX_PARAGRAPH_S = 60.0 # максимальная длительность абзаца _MAX_PARAGRAPH_S = 60.0 # максимальная длительность абзаца
@@ -88,6 +88,18 @@ def format_duration(seconds: float) -> str:
return f"{m:02d}:{s:02d}" return f"{m:02d}:{s:02d}"
def _format_speaker_timestamp(seconds: float, use_hours: bool) -> str:
total_seconds = int(seconds)
if use_hours:
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
secs = total_seconds % 60
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
minutes = total_seconds // 60
secs = total_seconds % 60
return f"{minutes:02d}:{secs:02d}"
def format_transcript( def format_transcript(
result: TranscribeResult, result: TranscribeResult,
source_filename: str, source_filename: str,
@@ -95,6 +107,8 @@ def format_transcript(
device_info: str, device_info: str,
language_mode: str, # см. LANGUAGE_MODES language_mode: str, # см. LANGUAGE_MODES
transcription_date: datetime | None = None, # None -> datetime.now() transcription_date: datetime | None = None, # None -> datetime.now()
speaker_transcript: SpeakerTranscript | None = None,
diarization_warning: str | None = None,
) -> str: ) -> str:
"""Собирает markdown-транскрипт: шапка с метаданными + абзацы с таймкодами.""" """Собирает markdown-транскрипт: шапка с метаданными + абзацы с таймкодами."""
date = transcription_date or datetime.now() date = transcription_date or datetime.now()
@@ -123,6 +137,24 @@ def format_transcript(
f"- **Внимание**: повторы в [{start} - {end}] ({block.count}×) " f"- **Внимание**: повторы в [{start} - {end}] ({block.count}×) "
"— возможны галлюцинации модели" "— возможны галлюцинации модели"
) )
if speaker_transcript is not None:
lines.append(f"- **Голосовых кластеров**: {speaker_transcript.cluster_count}")
if speaker_transcript.unassigned_word_count:
lines.append(
"- **Внимание**: "
f"{speaker_transcript.unassigned_word_count} слов без назначенного говорящего"
)
for cluster in speaker_transcript.small_clusters:
label = (
f"Speaker {cluster.speaker}"
if cluster.speaker is not None
else "кластер без номера"
)
lines.append(
f"- **Внимание**: малый кластер {label}: {cluster.duration:.1f} с"
)
if diarization_warning is not None:
lines.append(f"- **Внимание**: {diarization_warning}")
lines.append(f"- **Устройство**: {device_info}") lines.append(f"- **Устройство**: {device_info}")
lines.append("") lines.append("")
lines.append("---") lines.append("---")
@@ -130,6 +162,12 @@ def format_transcript(
if not result.segments: if not result.segments:
lines.append("") lines.append("")
lines.append("*Речь не обнаружена.*") lines.append("*Речь не обнаружена.*")
elif speaker_transcript is not None and speaker_transcript.cluster_count >= 2:
for turn in speaker_transcript.turns:
timestamp = _format_speaker_timestamp(turn.start, use_hours)
speaker = turn.speaker if turn.speaker is not None else "?"
lines.append("")
lines.append(f"[{timestamp}] Speaker {speaker}: {turn.text}")
else: else:
for para in _group_segments(result.segments): for para in _group_segments(result.segments):
start = format_timestamp(para.start, use_hours=use_hours) start = format_timestamp(para.start, use_hours=use_hours)
+222
View File
@@ -0,0 +1,222 @@
"""Адаптер офлайн-диаризации через sherpa-onnx."""
import shutil
import tarfile
from hashlib import sha256
from pathlib import Path
from tempfile import NamedTemporaryFile
from time import perf_counter
from typing import Any
from .types import DiarizationRun, SpeakerInterval, StatusCallback
_SAMPLE_RATE = 16_000
_CLUSTERING_THRESHOLD = 0.89
_SEGMENTATION_FILENAME = "pyannote-segmentation-3.0.onnx"
_EMBEDDING_FILENAME = "wespeaker_en_voxceleb_resnet34_LM.onnx"
_SEGMENTATION_SHA256 = (
"220ad67ca923bef2fa91f2390c786097bf305bceb5e261d4af67b38e938e1079"
)
_EMBEDDING_SHA256 = "e9848563da86f263117134dfd7ad63c92355b37de492b55e325400c9d9c39012"
_SEGMENTATION_URL = (
"https://github.com/k2-fsa/sherpa-onnx/releases/download/"
"speaker-segmentation-models/"
"sherpa-onnx-pyannote-segmentation-3-0.tar.bz2"
)
_SEGMENTATION_ARCHIVE_MEMBER = "sherpa-onnx-pyannote-segmentation-3-0/model.onnx"
_EMBEDDING_URL = (
"https://github.com/k2-fsa/sherpa-onnx/releases/download/"
"speaker-recongition-models/wespeaker_en_voxceleb_resnet34_LM.onnx"
)
class SpeakerDiarizer:
"""Переиспользуемый в пределах команды диаризатор."""
def __init__(self, engine: Any):
self._engine = engine
def process(
self,
file_path: Path,
on_status: StatusCallback = None,
) -> DiarizationRun:
"""Строит разметку говорящих для одного файла."""
from faster_whisper import decode_audio
if on_status is not None:
on_status("Загружаю аудио для диаризации...")
samples = decode_audio(str(file_path), sampling_rate=_SAMPLE_RATE)
if isinstance(samples, tuple):
raise TypeError("Декодер неожиданно вернул раздельные стереоканалы")
if on_status is not None:
on_status("Определяю говорящих...")
started = perf_counter()
if on_status is None:
result = self._engine.process(samples)
else:
def report_progress(processed: int, total: int) -> int:
on_status(f"Определяю говорящих... {processed} / {total}")
return 0
result = self._engine.process(samples, report_progress)
elapsed = perf_counter() - started
intervals = [
SpeakerInterval(
start=float(segment.start),
end=float(segment.end),
cluster=int(segment.speaker),
)
for segment in result.sort_by_start_time()
]
return DiarizationRun(intervals=intervals, elapsed_seconds=elapsed)
def load_speaker_diarizer(
speakers: int | None,
threads: int = 0,
on_status: StatusCallback = None,
) -> SpeakerDiarizer:
"""Проверяет модели и создаёт batch-owned диаризатор."""
import sherpa_onnx
from huggingface_hub import cached_assets_path
cache_dir = cached_assets_path(
library_name="local-transcriber",
namespace="diarization",
subfolder="models-v1",
)
segmentation_path = cache_dir / _SEGMENTATION_FILENAME
embedding_path = cache_dir / _EMBEDDING_FILENAME
_ensure_cached_model(
segmentation_path,
_SEGMENTATION_SHA256,
_SEGMENTATION_URL,
on_status,
archive_member=_SEGMENTATION_ARCHIVE_MEMBER,
)
_ensure_cached_model(
embedding_path,
_EMBEDDING_SHA256,
_EMBEDDING_URL,
on_status,
)
if on_status is not None:
on_status("Инициализирую диаризатор...")
segmentation_kwargs: dict[str, Any] = {
"pyannote": sherpa_onnx.OfflineSpeakerSegmentationPyannoteModelConfig(
model=str(segmentation_path)
),
"provider": "cpu",
}
embedding_kwargs: dict[str, Any] = {
"model": str(embedding_path),
"provider": "cpu",
}
if threads > 0:
segmentation_kwargs["num_threads"] = threads
embedding_kwargs["num_threads"] = threads
config = sherpa_onnx.OfflineSpeakerDiarizationConfig(
segmentation=sherpa_onnx.OfflineSpeakerSegmentationModelConfig(
**segmentation_kwargs
),
embedding=sherpa_onnx.SpeakerEmbeddingExtractorConfig(**embedding_kwargs),
clustering=sherpa_onnx.FastClusteringConfig(
num_clusters=speakers if speakers is not None else -1,
threshold=_CLUSTERING_THRESHOLD,
),
min_duration_on=0.3,
min_duration_off=0.5,
)
if not config.validate():
raise RuntimeError("Конфигурация диаризатора недействительна")
engine = sherpa_onnx.OfflineSpeakerDiarization(config)
if engine.sample_rate != _SAMPLE_RATE:
raise RuntimeError(
f"Диаризатор ожидает частоту {engine.sample_rate} Гц вместо {_SAMPLE_RATE} Гц"
)
return SpeakerDiarizer(engine)
def _ensure_cached_model(
path: Path,
expected_sha256: str,
url: str,
on_status: StatusCallback,
archive_member: str | None = None,
) -> None:
if path.is_file() and _file_sha256(path) == expected_sha256:
return
import httpx
path.parent.mkdir(parents=True, exist_ok=True)
if on_status is not None:
on_status(f"Скачиваю модель диаризации {path.name}...")
download_path = _temporary_path(path)
extracted_path: Path | None = None
try:
with (
httpx.stream("GET", url, follow_redirects=True, timeout=60.0) as response,
download_path.open("wb") as output,
):
response.raise_for_status()
for chunk in response.iter_bytes():
output.write(chunk)
candidate = download_path
if archive_member is not None:
extracted_path = _temporary_path(path)
with tarfile.open(download_path, mode="r:bz2") as archive:
try:
member = archive.getmember(archive_member)
except KeyError as exc:
raise RuntimeError(
f"В архиве модели отсутствует {archive_member}"
) from exc
if not member.isfile():
raise RuntimeError(
f"Элемент архива модели не является файлом: {archive_member}"
)
source = archive.extractfile(member)
if source is None:
raise RuntimeError(f"Не удалось прочитать {archive_member}")
with source, extracted_path.open("wb") as output:
shutil.copyfileobj(source, output)
candidate = extracted_path
actual_sha256 = _file_sha256(candidate)
if actual_sha256 != expected_sha256:
raise RuntimeError(
f"Контрольная сумма модели {path.name} не совпала: {actual_sha256}"
)
candidate.replace(path)
finally:
download_path.unlink(missing_ok=True)
if extracted_path is not None:
extracted_path.unlink(missing_ok=True)
def _temporary_path(target: Path) -> Path:
with NamedTemporaryFile(
dir=target.parent,
prefix=f".{target.name}.",
suffix=".tmp",
delete=False,
) as temporary:
return Path(temporary.name)
def _file_sha256(path: Path) -> str:
digest = sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
+46 -14
View File
@@ -12,6 +12,7 @@ from local_transcriber.types import ( # noqa: F401
Segment, Segment,
TranscribeFileResult, TranscribeFileResult,
TranscribeResult, TranscribeResult,
WordTimestampsUnavailableError,
) )
@@ -37,27 +38,36 @@ def load_model(
try: try:
_notify_status(on_status, f"Инициализирую модель на {device}...") _notify_status(on_status, f"Инициализирую модель на {device}...")
model = backend.create_model(model_path, device, compute_type, cpu_threads=cpu_threads) model = backend.create_model(
model_path, device, compute_type, cpu_threads=cpu_threads
)
# Резолвим actual_device по реальному OpenVINO device # Резолвим actual_device по реальному OpenVINO device
ov_dev = getattr(backend, "actual_ov_device", None) ov_dev = getattr(backend, "actual_ov_device", None)
if ov_dev == "GPU" and actual_device != "openvino-gpu": if ov_dev == "GPU" and actual_device != "openvino-gpu":
actual_device = "openvino-gpu" actual_device = "openvino-gpu"
elif ov_dev == "CPU" and actual_device.startswith("openvino") and actual_device != "openvino-cpu": elif (
ov_dev == "CPU"
and actual_device.startswith("openvino")
and actual_device != "openvino-cpu"
):
actual_device = "openvino-cpu" actual_device = "openvino-cpu"
except (RuntimeError, ValueError) as exc: except (RuntimeError, ValueError) as exc:
if device != "cpu" and _is_backend_error(exc, device): if device != "cpu" and _is_backend_error(exc, device):
if strict_device: if strict_device:
raise raise
warnings.warn( warnings.warn(
f"Не удалось загрузить модель на {device}: {exc}. " f"Не удалось загрузить модель на {device}: {exc}. Переключение на CPU.",
"Переключение на CPU.",
stacklevel=2, stacklevel=2,
) )
actual_device = "cpu" actual_device = "cpu"
backend = get_backend("cpu") backend = get_backend("cpu")
model_path = backend.ensure_model_available(model_name, compute_type, on_status) model_path = backend.ensure_model_available(
model_name, compute_type, on_status
)
_notify_status(on_status, "Инициализирую модель на cpu...") _notify_status(on_status, "Инициализирую модель на cpu...")
model = backend.create_model(model_path, "cpu", compute_type, cpu_threads=cpu_threads) model = backend.create_model(
model_path, "cpu", compute_type, cpu_threads=cpu_threads
)
else: else:
raise raise
@@ -96,11 +106,17 @@ def _transcribe_file(
) )
actual_device = "cpu" actual_device = "cpu"
backend = get_backend("cpu") backend = get_backend("cpu")
model_path = backend.ensure_model_available(model_name, compute_type, on_status) model_path = backend.ensure_model_available(
model_name, compute_type, on_status
)
_notify_status(on_status, "Инициализирую модель на cpu...") _notify_status(on_status, "Инициализирую модель на cpu...")
model = backend.create_model(model_path, "cpu", compute_type, cpu_threads=cpu_threads) model = backend.create_model(
model_path, "cpu", compute_type, cpu_threads=cpu_threads
)
_notify_status(on_status, "Транскрибирую...") _notify_status(on_status, "Транскрибирую...")
result = backend.transcribe(model, file_path, lang_arg, on_segment, on_status) result = backend.transcribe(
model, file_path, lang_arg, on_segment, on_status
)
result.device_used = actual_device result.device_used = actual_device
else: else:
raise raise
@@ -127,14 +143,26 @@ def transcribe(
) -> TranscribeResult: ) -> TranscribeResult:
"""High-level API: загрузка модели + транскрипция за один вызов.""" """High-level API: загрузка модели + транскрипция за один вызов."""
model, actual_device, backend, model_path = load_model( model, actual_device, backend, model_path = load_model(
model_name, device, compute_type, on_status, strict_device, model_name,
device,
compute_type,
on_status,
strict_device,
compute_type_explicit=True, # Python API — caller explicitly chose compute_type compute_type_explicit=True, # Python API — caller explicitly chose compute_type
cpu_threads=cpu_threads, cpu_threads=cpu_threads,
) )
tfr = _transcribe_file( tfr = _transcribe_file(
model, actual_device, backend, model_path, model,
file_path, model_name, compute_type, actual_device,
language, on_segment, on_status, strict_device, backend,
model_path,
file_path,
model_name,
compute_type,
language,
on_segment,
on_status,
strict_device,
cpu_threads=cpu_threads, cpu_threads=cpu_threads,
) )
return tfr.result return tfr.result
@@ -151,7 +179,9 @@ def ensure_model_available(
if compute_type is None: if compute_type is None:
device_defs = DEVICE_DEFAULTS.get(device, {}) device_defs = DEVICE_DEFAULTS.get(device, {})
compute_type = device_defs.get("compute_type", HARDCODED_DEFAULTS["compute_type"]) compute_type = device_defs.get(
"compute_type", HARDCODED_DEFAULTS["compute_type"]
)
explicit = False explicit = False
else: else:
explicit = True explicit = True
@@ -167,6 +197,8 @@ def _is_cuda_error(exc: BaseException) -> bool:
def _is_backend_error(exc: BaseException, device: str) -> bool: def _is_backend_error(exc: BaseException, device: str) -> bool:
"""Определяет, связана ли ошибка с конкретным бэкендом (а не с пользовательскими данными).""" """Определяет, связана ли ошибка с конкретным бэкендом (а не с пользовательскими данными)."""
if isinstance(exc, WordTimestampsUnavailableError):
return False
if device in ("cuda", "cpu"): if device in ("cuda", "cpu"):
return _is_cuda_error(exc) return _is_cuda_error(exc)
if device.startswith("openvino"): if device.startswith("openvino"):
+60 -1
View File
@@ -1,13 +1,17 @@
"""Общие типы данных для всех бэкендов транскрипции.""" """Общие типы данных для всех бэкендов транскрипции."""
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import Any from typing import Any
# Единый признак «язык неизвестен» для всех бэкендов # Единый признак «язык неизвестен» для всех бэкендов
UNKNOWN_LANGUAGE = "unknown" UNKNOWN_LANGUAGE = "unknown"
class WordTimestampsUnavailableError(RuntimeError):
"""ASR распознал текст, но нарушил обязательный пословный контракт."""
@dataclass @dataclass
class Segment: class Segment:
start: float # seconds start: float # seconds
@@ -15,6 +19,60 @@ class Segment:
text: str text: str
@dataclass(frozen=True)
class Word:
"""Слово с временной привязкой на шкале исходной записи."""
start: float
end: float
text: str
@dataclass(frozen=True)
class SpeakerInterval:
"""Интервал разметки говорящих с анонимным голосовым кластером."""
start: float
end: float
cluster: int
@dataclass(frozen=True)
class SpeakerTurn:
"""Реплика говорящего; ``speaker=None`` означает неизвестного говорящего."""
start: float
end: float
text: str
speaker: int | None
@dataclass(frozen=True)
class SmallSpeakerCluster:
"""Малый голосовой кластер, о котором нужно предупредить пользователя."""
speaker: int | None
duration: float
@dataclass
class SpeakerTranscript:
"""Результат сведения слов с разметкой говорящих."""
turns: list[SpeakerTurn]
cluster_count: int
unassigned_word_count: int
small_clusters: list[SmallSpeakerCluster]
@dataclass
class DiarizationRun:
"""Разметка одного файла и длительность прохода диаризации."""
intervals: list[SpeakerInterval]
elapsed_seconds: float
@dataclass @dataclass
class TranscribeResult: class TranscribeResult:
segments: list[Segment] segments: list[Segment]
@@ -22,6 +80,7 @@ class TranscribeResult:
language_probability: float language_probability: float
duration: float # seconds duration: float # seconds
device_used: str # "cpu" / "cuda" / "onnx" / "openvino-gpu" / "openvino-cpu" device_used: str # "cpu" / "cuda" / "onnx" / "openvino-gpu" / "openvino-cpu"
words: list[Word] = field(default_factory=list)
@dataclass @dataclass
+66
View File
@@ -0,0 +1,66 @@
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from local_transcriber.backends.faster_whisper import FasterWhisperBackend
from local_transcriber.types import Word
def test_transcribe_returns_canonical_words(tmp_path):
audio = tmp_path / "audio.wav"
raw_word = SimpleNamespace(start=0.2, end=0.7, word=" Привет")
raw_segment = SimpleNamespace(
start=0.0,
end=1.0,
text=" Привет",
words=[raw_word],
)
info = SimpleNamespace(duration=1.0, language="ru", language_probability=0.99)
model = MagicMock()
model.transcribe.return_value = (iter([raw_segment]), info)
result = FasterWhisperBackend().transcribe(model, audio, language="ru")
assert result.words == [Word(start=0.2, end=0.7, text=" Привет")]
model.transcribe.assert_called_once_with(
str(audio),
language="ru",
word_timestamps=True,
)
def test_transcribe_rejects_nonempty_result_without_word_timestamps(tmp_path):
raw_segment = SimpleNamespace(
start=0.0,
end=1.0,
text=" Текст есть",
words=None,
)
info = SimpleNamespace(duration=1.0, language="ru", language_probability=1.0)
model = MagicMock()
model.transcribe.return_value = (iter([raw_segment]), info)
with pytest.raises(RuntimeError, match="пословные таймкоды"):
FasterWhisperBackend().transcribe(model, tmp_path / "audio.wav", "ru")
def test_transcribe_rejects_one_nonempty_segment_without_word_timestamps(tmp_path):
timestamped = SimpleNamespace(
start=0.0,
end=1.0,
text=" Первое",
words=[SimpleNamespace(start=0.0, end=1.0, word=" Первое")],
)
missing = SimpleNamespace(
start=1.0,
end=2.0,
text=" Второе",
words=None,
)
info = SimpleNamespace(duration=2.0, language="ru", language_probability=1.0)
model = MagicMock()
model.transcribe.return_value = (iter([timestamped, missing]), info)
with pytest.raises(RuntimeError, match="пословные таймкоды"):
FasterWhisperBackend().transcribe(model, tmp_path / "audio.wav", "ru")
+140 -20
View File
@@ -11,8 +11,7 @@ from local_transcriber.backends.openvino import (
OpenVINOBackend, OpenVINOBackend,
_validate_model_dir, _validate_model_dir,
) )
from local_transcriber.types import UNKNOWN_LANGUAGE, Segment from local_transcriber.types import UNKNOWN_LANGUAGE, Segment, Word
# === _resolve_repo === # === _resolve_repo ===
@@ -28,12 +27,18 @@ def test_model_catalog_contains_large_v3_turbo_profiles():
def test_resolve_repo_exact_match(): def test_resolve_repo_exact_match():
backend = OpenVINOBackend(compute_type_explicit=True) backend = OpenVINOBackend(compute_type_explicit=True)
assert backend._resolve_repo("medium", "int8") == ("OpenVINO/whisper-medium-int8-ov", "int8") assert backend._resolve_repo("medium", "int8") == (
"OpenVINO/whisper-medium-int8-ov",
"int8",
)
def test_resolve_repo_large_v3_fp16(): def test_resolve_repo_large_v3_fp16():
backend = OpenVINOBackend(compute_type_explicit=True) backend = OpenVINOBackend(compute_type_explicit=True)
assert backend._resolve_repo("large-v3", "fp16") == ("OpenVINO/whisper-large-v3-fp16-ov", "fp16") assert backend._resolve_repo("large-v3", "fp16") == (
"OpenVINO/whisper-large-v3-fp16-ov",
"fp16",
)
def test_resolve_repo_explicit_unsupported_pair_raises(): def test_resolve_repo_explicit_unsupported_pair_raises():
@@ -53,16 +58,17 @@ def test_resolve_repo_implicit_fallback():
"""Неявный compute_type: если int8 недоступен для base, fallback на fp16.""" """Неявный compute_type: если int8 недоступен для base, fallback на fp16."""
backend = OpenVINOBackend(compute_type_explicit=False) backend = OpenVINOBackend(compute_type_explicit=False)
# base + int8 не существует, но base + fp16 есть # base + int8 не существует, но base + fp16 есть
assert backend._resolve_repo("base", "int8") == ("OpenVINO/whisper-base-fp16-ov", "fp16") assert backend._resolve_repo("base", "int8") == (
"OpenVINO/whisper-base-fp16-ov",
"fp16",
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
("model_name", "expected_compute_type"), ("model_name", "expected_compute_type"),
[("large-v3", "fp16"), ("large-v3-turbo", "int8")], [("large-v3", "fp16"), ("large-v3-turbo", "int8")],
) )
def test_resolve_repo_implicit_large_v3_profiles( def test_resolve_repo_implicit_large_v3_profiles(model_name, expected_compute_type):
model_name, expected_compute_type
):
"""Неявный compute_type различает обычную и turbo-модель.""" """Неявный compute_type различает обычную и turbo-модель."""
backend = OpenVINOBackend(compute_type_explicit=False) backend = OpenVINOBackend(compute_type_explicit=False)
@@ -75,7 +81,10 @@ def test_resolve_repo_implicit_large_v3_profiles(
def test_resolve_repo_explicit_large_v3_int8_respected(): def test_resolve_repo_explicit_large_v3_int8_respected():
"""Явный --compute-type int8 для large-v3 → уважается.""" """Явный --compute-type int8 для large-v3 → уважается."""
backend = OpenVINOBackend(compute_type_explicit=True) backend = OpenVINOBackend(compute_type_explicit=True)
assert backend._resolve_repo("large-v3", "int8") == ("OpenVINO/whisper-large-v3-int8-ov", "int8") assert backend._resolve_repo("large-v3", "int8") == (
"OpenVINO/whisper-large-v3-int8-ov",
"int8",
)
@pytest.mark.parametrize("compute_type", ["int8", "fp16"]) @pytest.mark.parametrize("compute_type", ["int8", "fp16"])
@@ -107,6 +116,7 @@ def test_ensure_model_available_cache_hit(mock_download, tmp_path):
model_dir.mkdir() model_dir.mkdir()
(model_dir / "openvino_encoder_model.xml").write_text("<xml/>") (model_dir / "openvino_encoder_model.xml").write_text("<xml/>")
(model_dir / "openvino_decoder_model.xml").write_text("<xml/>") (model_dir / "openvino_decoder_model.xml").write_text("<xml/>")
(model_dir / "generation_config.json").write_text('{"alignment_heads": [[1, 2]]}')
mock_download.return_value = str(model_dir) mock_download.return_value = str(model_dir)
backend = OpenVINOBackend(compute_type_explicit=True) backend = OpenVINOBackend(compute_type_explicit=True)
@@ -125,6 +135,7 @@ def test_ensure_model_available_downloads(mock_download, tmp_path):
model_dir.mkdir() model_dir.mkdir()
(model_dir / "openvino_encoder_model.xml").write_text("<xml/>") (model_dir / "openvino_encoder_model.xml").write_text("<xml/>")
(model_dir / "openvino_decoder_model.xml").write_text("<xml/>") (model_dir / "openvino_decoder_model.xml").write_text("<xml/>")
(model_dir / "generation_config.json").write_text('{"alignment_heads": [[1, 2]]}')
mock_download.side_effect = [ mock_download.side_effect = [
LocalEntryNotFoundError("not cached"), LocalEntryNotFoundError("not cached"),
@@ -145,6 +156,7 @@ def test_large_v3_turbo_model_is_resolved_and_created(mock_download, tmp_path):
model_dir.mkdir() model_dir.mkdir()
(model_dir / "openvino_encoder_model.xml").write_text("<xml/>") (model_dir / "openvino_encoder_model.xml").write_text("<xml/>")
(model_dir / "openvino_decoder_model.xml").write_text("<xml/>") (model_dir / "openvino_decoder_model.xml").write_text("<xml/>")
(model_dir / "generation_config.json").write_text('{"alignment_heads": [[1, 2]]}')
mock_download.return_value = str(model_dir) mock_download.return_value = str(model_dir)
mock_ov = MagicMock() mock_ov = MagicMock()
@@ -157,12 +169,28 @@ def test_large_v3_turbo_model_is_resolved_and_created(mock_download, tmp_path):
"OpenVINO/whisper-large-v3-turbo-int8-ov", "OpenVINO/whisper-large-v3-turbo-int8-ov",
local_files_only=True, local_files_only=True,
) )
mock_ov.WhisperPipeline.assert_called_once_with(str(model_dir), "CPU") mock_ov.WhisperPipeline.assert_called_once_with(
str(model_dir), "CPU", word_timestamps=True
)
# === create_model === # === create_model ===
def test_create_model_enables_word_timestamps():
mock_ov = MagicMock()
backend = OpenVINOBackend(ov_device="openvino-cpu")
with patch.dict("sys.modules", {"openvino_genai": mock_ov}):
backend.create_model("/path/to/model", "openvino-cpu", "int8")
mock_ov.WhisperPipeline.assert_called_once_with(
"/path/to/model",
"CPU",
word_timestamps=True,
)
def test_create_model_cpu(): def test_create_model_cpu():
mock_ov = MagicMock() mock_ov = MagicMock()
mock_pipeline = MagicMock() mock_pipeline = MagicMock()
@@ -172,7 +200,9 @@ def test_create_model_cpu():
with patch.dict("sys.modules", {"openvino_genai": mock_ov}): with patch.dict("sys.modules", {"openvino_genai": mock_ov}):
model = backend.create_model("/path/to/model", "openvino-cpu", "int8") model = backend.create_model("/path/to/model", "openvino-cpu", "int8")
mock_ov.WhisperPipeline.assert_called_once_with("/path/to/model", "CPU") mock_ov.WhisperPipeline.assert_called_once_with(
"/path/to/model", "CPU", word_timestamps=True
)
assert model is mock_pipeline assert model is mock_pipeline
assert backend.actual_ov_device == "CPU" assert backend.actual_ov_device == "CPU"
@@ -186,7 +216,9 @@ def test_create_model_gpu():
with patch.dict("sys.modules", {"openvino_genai": mock_ov}): with patch.dict("sys.modules", {"openvino_genai": mock_ov}):
model = backend.create_model("/path/to/model", "openvino-gpu", "fp16") model = backend.create_model("/path/to/model", "openvino-gpu", "fp16")
mock_ov.WhisperPipeline.assert_called_once_with("/path/to/model", "GPU") mock_ov.WhisperPipeline.assert_called_once_with(
"/path/to/model", "GPU", word_timestamps=True
)
assert model is mock_pipeline assert model is mock_pipeline
assert backend.actual_ov_device == "GPU" assert backend.actual_ov_device == "GPU"
@@ -202,11 +234,16 @@ def test_create_model_openvino_auto_detects_gpu():
backend = OpenVINOBackend(ov_device="openvino") backend = OpenVINOBackend(ov_device="openvino")
with ( with (
patch.dict("sys.modules", {"openvino_genai": mock_ov, "openvino": MagicMock(Core=mock_core)}), patch.dict(
"sys.modules",
{"openvino_genai": mock_ov, "openvino": MagicMock(Core=mock_core)},
),
): ):
model = backend.create_model("/path/to/model", "openvino", "int8") backend.create_model("/path/to/model", "openvino", "int8")
mock_ov.WhisperPipeline.assert_called_once_with("/path/to/model", "GPU") mock_ov.WhisperPipeline.assert_called_once_with(
"/path/to/model", "GPU", word_timestamps=True
)
assert backend.actual_ov_device == "GPU" assert backend.actual_ov_device == "GPU"
@@ -221,11 +258,16 @@ def test_create_model_openvino_auto_falls_back_to_cpu():
backend = OpenVINOBackend(ov_device="openvino") backend = OpenVINOBackend(ov_device="openvino")
with ( with (
patch.dict("sys.modules", {"openvino_genai": mock_ov, "openvino": MagicMock(Core=mock_core)}), patch.dict(
"sys.modules",
{"openvino_genai": mock_ov, "openvino": MagicMock(Core=mock_core)},
),
): ):
model = backend.create_model("/path/to/model", "openvino", "int8") backend.create_model("/path/to/model", "openvino", "int8")
mock_ov.WhisperPipeline.assert_called_once_with("/path/to/model", "CPU") mock_ov.WhisperPipeline.assert_called_once_with(
"/path/to/model", "CPU", word_timestamps=True
)
assert backend.actual_ov_device == "CPU" assert backend.actual_ov_device == "CPU"
@@ -248,13 +290,19 @@ def test_transcribe_maps_chunks_to_segments():
mock_result = MagicMock() mock_result = MagicMock()
mock_result.chunks = [chunk1, chunk2] mock_result.chunks = [chunk1, chunk2]
mock_result.words = [
MagicMock(start_ts=0.0, end_ts=3.5, word=" Привет мир"),
MagicMock(start_ts=3.5, end_ts=7.0, word=" Тестовый сегмент"),
]
mock_model.generate.return_value = mock_result mock_model.generate.return_value = mock_result
raw_audio = np.zeros(16000 * 10, dtype=np.float32) # 10 секунд raw_audio = np.zeros(16000 * 10, dtype=np.float32) # 10 секунд
with patch("faster_whisper.decode_audio", return_value=raw_audio): with patch("faster_whisper.decode_audio", return_value=raw_audio):
result = backend.transcribe( result = backend.transcribe(
mock_model, Path("test.mp3"), language="ru", mock_model,
Path("test.mp3"),
language="ru",
) )
assert len(result.segments) == 2 assert len(result.segments) == 2
@@ -269,6 +317,61 @@ def test_transcribe_maps_chunks_to_segments():
assert call_kwargs.kwargs["return_timestamps"] is True assert call_kwargs.kwargs["return_timestamps"] is True
def test_transcribe_maps_word_level_timestamps():
backend = OpenVINOBackend()
mock_model = MagicMock()
raw_word = MagicMock()
raw_word.start_ts = 0.2
raw_word.end_ts = 0.8
raw_word.word = " Привет"
mock_result = MagicMock()
mock_result.chunks = []
mock_result.words = [raw_word]
mock_model.generate.return_value = mock_result
with patch(
"faster_whisper.decode_audio",
return_value=np.zeros(16_000, dtype=np.float32),
):
result = backend.transcribe(mock_model, Path("test.mp3"), language="ru")
assert result.words == [Word(start=0.2, end=0.8, text=" Привет")]
assert mock_model.generate.call_args.kwargs["word_timestamps"] is True
def test_transcribe_keeps_zero_duration_word_timestamp():
backend = OpenVINOBackend()
mock_model = MagicMock()
raw_word = MagicMock(start_ts=1.0, end_ts=1.0, word=" Слово")
mock_result = MagicMock(chunks=[], words=[raw_word])
mock_model.generate.return_value = mock_result
with patch(
"faster_whisper.decode_audio",
return_value=np.zeros(16_000, dtype=np.float32),
):
result = backend.transcribe(mock_model, Path("test.mp3"), language="ru")
assert result.words == [Word(start=1.0, end=1.0, text=" Слово")]
def test_transcribe_rejects_nonempty_result_without_word_timestamps():
backend = OpenVINOBackend()
chunk = MagicMock(start_ts=0.0, end_ts=1.0, text=" Текст")
mock_result = MagicMock(chunks=[chunk], words=None)
mock_model = MagicMock()
mock_model.generate.return_value = mock_result
with (
patch(
"faster_whisper.decode_audio",
return_value=np.zeros(16_000, dtype=np.float32),
),
pytest.raises(RuntimeError, match="пословные таймкоды"),
):
backend.transcribe(mock_model, Path("test.mp3"), language="ru")
def test_transcribe_calls_tolist(): def test_transcribe_calls_tolist():
"""raw_speech передаётся как list, не ndarray.""" """raw_speech передаётся как list, не ndarray."""
backend = OpenVINOBackend() backend = OpenVINOBackend()
@@ -314,6 +417,7 @@ def test_transcribe_calls_on_segment():
chunk.text = " Test" chunk.text = " Test"
mock_result = MagicMock() mock_result = MagicMock()
mock_result.chunks = [chunk] mock_result.chunks = [chunk]
mock_result.words = [MagicMock(start_ts=0.0, end_ts=2.0, word=" Test")]
mock_model.generate.return_value = mock_result mock_model.generate.return_value = mock_result
raw_audio = np.zeros(16000, dtype=np.float32) raw_audio = np.zeros(16000, dtype=np.float32)
@@ -321,7 +425,10 @@ def test_transcribe_calls_on_segment():
with patch("faster_whisper.decode_audio", return_value=raw_audio): with patch("faster_whisper.decode_audio", return_value=raw_audio):
backend.transcribe( backend.transcribe(
mock_model, Path("test.mp3"), language="en", on_segment=callback, mock_model,
Path("test.mp3"),
language="en",
on_segment=callback,
) )
callback.assert_called_once() callback.assert_called_once()
@@ -336,6 +443,7 @@ def test_transcribe_calls_on_segment():
def test_validate_model_dir_ok(tmp_path): def test_validate_model_dir_ok(tmp_path):
(tmp_path / "openvino_encoder_model.xml").write_text("<xml/>") (tmp_path / "openvino_encoder_model.xml").write_text("<xml/>")
(tmp_path / "openvino_decoder_model.xml").write_text("<xml/>") (tmp_path / "openvino_decoder_model.xml").write_text("<xml/>")
(tmp_path / "generation_config.json").write_text('{"alignment_heads": [[1, 2]]}')
_validate_model_dir(tmp_path) # should not raise _validate_model_dir(tmp_path) # should not raise
@@ -343,3 +451,15 @@ def test_validate_model_dir_missing(tmp_path):
(tmp_path / "openvino_encoder_model.xml").write_text("<xml/>") (tmp_path / "openvino_encoder_model.xml").write_text("<xml/>")
with pytest.raises(ValueError, match="openvino_decoder_model.xml"): with pytest.raises(ValueError, match="openvino_decoder_model.xml"):
_validate_model_dir(tmp_path) _validate_model_dir(tmp_path)
def test_validate_model_dir_requires_alignment_heads_for_word_timestamps(tmp_path):
(tmp_path / "openvino_encoder_model.xml").write_text("<xml/>")
(tmp_path / "openvino_decoder_model.xml").write_text("<xml/>")
(tmp_path / "generation_config.json").write_text(
'{"alignment_heads": []}',
encoding="utf-8",
)
with pytest.raises(ValueError, match="alignment_heads"):
_validate_model_dir(tmp_path)
+580 -64
View File
@@ -12,15 +12,26 @@ from local_transcriber.formatter import (
LANGUAGE_FROM_MODEL, LANGUAGE_FROM_MODEL,
LANGUAGE_UNKNOWN, LANGUAGE_UNKNOWN,
) )
from local_transcriber.transcriber import Segment, TranscribeFileResult, TranscribeResult from local_transcriber.transcriber import (
from local_transcriber.types import UNKNOWN_LANGUAGE Segment,
TranscribeFileResult,
TranscribeResult,
)
from local_transcriber.types import (
UNKNOWN_LANGUAGE,
DiarizationRun,
SpeakerInterval,
Word,
)
runner = CliRunner() runner = CliRunner()
def _make_result(segments=None, language="ru", device_used="cpu", duration=60.0): def _make_result(segments=None, language="ru", device_used="cpu", duration=60.0):
return TranscribeResult( return TranscribeResult(
segments=[Segment(start=0.0, end=2.0, text="Hello")] if segments is None else segments, segments=[Segment(start=0.0, end=2.0, text="Hello")]
if segments is None
else segments,
language=language, language=language,
language_probability=0.95, language_probability=0.95,
duration=duration, duration=duration,
@@ -36,7 +47,13 @@ def _make_backend():
return MagicMock(name="Backend") return MagicMock(name="Backend")
def _make_tfr(result=None, model=None, actual_device="cpu", backend=None, model_path="/models/medium"): def _make_tfr(
result=None,
model=None,
actual_device="cpu",
backend=None,
model_path="/models/medium",
):
if result is None: if result is None:
result = _make_result() result = _make_result()
if model is None: if model is None:
@@ -44,8 +61,11 @@ def _make_tfr(result=None, model=None, actual_device="cpu", backend=None, model_
if backend is None: if backend is None:
backend = _make_backend() backend = _make_backend()
return TranscribeFileResult( return TranscribeFileResult(
result=result, model=model, actual_device=actual_device, result=result,
backend=backend, model_path=model_path, model=model,
actual_device=actual_device,
backend=backend,
model_path=model_path,
) )
@@ -58,9 +78,7 @@ def _make_tfr(result=None, model=None, actual_device="cpu", backend=None, model_
("auto", UNKNOWN_LANGUAGE, 0.0, LANGUAGE_UNKNOWN), ("auto", UNKNOWN_LANGUAGE, 0.0, LANGUAGE_UNKNOWN),
], ],
) )
def test_format_language_mode( def test_format_language_mode(requested_language, language, probability, expected):
requested_language, language, probability, expected
):
result = _make_result(language=language) result = _make_result(language=language)
result.language_probability = probability result.language_probability = probability
@@ -73,12 +91,17 @@ def _single_patches(result=None, tmp_file=None, actual_device="cpu"):
result = _make_result(device_used=actual_device) result = _make_result(device_used=actual_device)
model = _make_model() model = _make_model()
backend = _make_backend() backend = _make_backend()
tfr = _make_tfr(result=result, model=model, actual_device=actual_device, backend=backend) tfr = _make_tfr(
result=result, model=model, actual_device=actual_device, backend=backend
)
return [ return [
patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", return_value=tmp_file), patch("local_transcriber.cli.validate_input_file", return_value=tmp_file),
patch("local_transcriber.cli.detect_device", return_value=actual_device), patch("local_transcriber.cli.detect_device", return_value=actual_device),
patch("local_transcriber.cli.load_model", return_value=(model, actual_device, backend, "/models/medium")), patch(
"local_transcriber.cli.load_model",
return_value=(model, actual_device, backend, "/models/medium"),
),
patch("local_transcriber.cli._transcribe_file", return_value=tfr), patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch("local_transcriber.cli.write_transcript"), patch("local_transcriber.cli.write_transcript"),
] ]
@@ -139,18 +162,28 @@ def test_cli_custom_options(tmp_path):
patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", return_value=audio), patch("local_transcriber.cli.validate_input_file", return_value=audio),
patch("local_transcriber.cli.detect_device", return_value="cuda"), patch("local_transcriber.cli.detect_device", return_value="cuda"),
patch("local_transcriber.cli.load_model", return_value=(model, "cuda", backend, "/models/small")), patch(
"local_transcriber.cli.load_model",
return_value=(model, "cuda", backend, "/models/small"),
),
patch("local_transcriber.cli._transcribe_file", mock_transcribe_file), patch("local_transcriber.cli._transcribe_file", mock_transcribe_file),
patch("local_transcriber.cli.write_transcript"), patch("local_transcriber.cli.write_transcript"),
patch("local_transcriber.cli.get_gpu_name", return_value="RTX 3060"), patch("local_transcriber.cli.get_gpu_name", return_value="RTX 3060"),
): ):
runner.invoke(app, [ runner.invoke(
str(audio), app,
"--model", "small", [
"--language", "ru", str(audio),
"--device", "cuda", "--model",
"--compute-type", "float16", "small",
]) "--language",
"ru",
"--device",
"cuda",
"--compute-type",
"float16",
],
)
call_kwargs = mock_transcribe_file.call_args[1] call_kwargs = mock_transcribe_file.call_args[1]
assert call_kwargs["model_name"] == "small" assert call_kwargs["model_name"] == "small"
@@ -158,6 +191,266 @@ def test_cli_custom_options(tmp_path):
assert call_kwargs["compute_type"] == "float16" assert call_kwargs["compute_type"] == "float16"
def test_cli_speakers_enables_diarization_and_writes_speaker_markdown(tmp_path):
audio = tmp_path / "meeting.mp3"
audio.write_bytes(b"fake")
result = _make_result(
segments=[Segment(0.0, 1.3, "Первый. Второй. Неясно.")],
duration=10.0,
)
result.words = [
Word(0.0, 0.5, "Первый."),
Word(0.5, 1.0, "Второй."),
Word(1.1, 1.3, "Неясно."),
]
model = _make_model()
backend = _make_backend()
backend.word_timestamps_available = True
tfr = _make_tfr(result=result, model=model, backend=backend)
diarizer = MagicMock()
diarizer.process.return_value = DiarizationRun(
intervals=[
SpeakerInterval(0.0, 0.5, 10),
SpeakerInterval(0.5, 1.0, 20),
],
elapsed_seconds=0.2,
)
write = MagicMock()
with (
patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", return_value=audio),
patch("local_transcriber.cli.detect_device", return_value="cpu"),
patch(
"local_transcriber.cli.load_model",
return_value=(model, "cpu", backend, "/models/medium"),
),
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch(
"local_transcriber.cli.load_speaker_diarizer",
return_value=diarizer,
) as load_diarizer,
patch("local_transcriber.cli.write_transcript", write),
):
out = runner.invoke(
app,
[str(audio), "--speakers", "2", "--threads", "3"],
)
assert out.exit_code == 0
load_diarizer.assert_called_once()
assert load_diarizer.call_args.kwargs["speakers"] == 2
assert load_diarizer.call_args.kwargs["threads"] == 3
diarizer.process.assert_called_once()
assert "Speaker 1: Первый." in write.call_args.args[0]
assert "Speaker 2: Второй." in write.call_args.args[0]
assert "Speaker ?: Неясно." in write.call_args.args[0]
assert "1 слов без назначенного говорящего" in out.output
assert "малый кластер Speaker 1: 0.5 с" in out.output
def test_cli_diarization_error_writes_plain_transcript_and_exits_nonzero(tmp_path):
audio = tmp_path / "meeting.mp3"
audio.write_bytes(b"fake")
result = _make_result(
segments=[Segment(0.0, 1.0, "Полезный текст.")],
duration=10.0,
)
result.words = [Word(0.0, 1.0, "Полезный текст.")]
model = _make_model()
backend = _make_backend()
backend.word_timestamps_available = True
tfr = _make_tfr(result=result, model=model, backend=backend)
diarizer = MagicMock()
diarizer.process.side_effect = RuntimeError("boom")
write = MagicMock()
with (
patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", return_value=audio),
patch("local_transcriber.cli.detect_device", return_value="cpu"),
patch(
"local_transcriber.cli.load_model",
return_value=(model, "cpu", backend, "/models/medium"),
),
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch(
"local_transcriber.cli.load_speaker_diarizer",
return_value=diarizer,
),
patch("local_transcriber.cli.write_transcript", write),
):
out = runner.invoke(app, [str(audio), "--diarize"])
assert out.exit_code == 1
assert write.call_count == 1
assert "Полезный текст." in write.call_args.args[0]
assert "Диаризация завершилась с ошибкой: boom" in write.call_args.args[0]
def test_cli_verbose_reports_diarization_counts_and_duration(tmp_path):
audio = tmp_path / "meeting.mp3"
audio.write_bytes(b"fake")
result = _make_result(
segments=[Segment(0.0, 1.0, "Раз два")],
duration=10.0,
)
result.words = [Word(0.0, 0.5, "Раз"), Word(0.5, 1.0, "два")]
model = _make_model()
backend = _make_backend()
backend.word_timestamps_available = True
tfr = _make_tfr(result=result, model=model, backend=backend)
diarizer = MagicMock()
diarizer.process.return_value = DiarizationRun(
intervals=[
SpeakerInterval(0.0, 0.5, 1),
SpeakerInterval(0.5, 1.0, 2),
],
elapsed_seconds=0.2,
)
with (
patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", return_value=audio),
patch("local_transcriber.cli.detect_device", return_value="cpu"),
patch(
"local_transcriber.cli.load_model",
return_value=(model, "cpu", backend, "/models/medium"),
),
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch(
"local_transcriber.cli.load_speaker_diarizer",
return_value=diarizer,
),
patch("local_transcriber.cli.write_transcript"),
):
out = runner.invoke(app, [str(audio), "--diarize", "--verbose"])
assert out.exit_code == 0
assert "2 кластеров, 2 интервалов" in out.output
assert "0.2 с" in out.output
def test_cli_empty_asr_skips_diarizer_and_reports_it(tmp_path):
audio = tmp_path / "silence.wav"
audio.write_bytes(b"fake")
result = _make_result(segments=[])
model = _make_model()
backend = _make_backend()
backend.word_timestamps_available = True
tfr = _make_tfr(result=result, model=model, backend=backend)
diarizer = MagicMock()
with (
patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", return_value=audio),
patch("local_transcriber.cli.detect_device", return_value="cpu"),
patch(
"local_transcriber.cli.load_model",
return_value=(model, "cpu", backend, "/models/medium"),
),
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch(
"local_transcriber.cli.load_speaker_diarizer",
return_value=diarizer,
),
patch("local_transcriber.cli.write_transcript"),
):
out = runner.invoke(app, [str(audio), "--diarize"])
assert out.exit_code == 0
diarizer.process.assert_not_called()
assert "диаризация не запускалась" in out.output
def test_cli_diarizer_preflight_failure_does_not_start_asr_or_write(tmp_path):
audio = tmp_path / "meeting.mp3"
audio.write_bytes(b"fake")
model = _make_model()
backend = _make_backend()
backend.word_timestamps_available = True
transcribe_file = MagicMock()
write = MagicMock()
with (
patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", return_value=audio),
patch("local_transcriber.cli.detect_device", return_value="cpu"),
patch(
"local_transcriber.cli.load_model",
return_value=(model, "cpu", backend, "/models/medium"),
),
patch("local_transcriber.cli._transcribe_file", transcribe_file),
patch(
"local_transcriber.cli.load_speaker_diarizer",
side_effect=RuntimeError("модель повреждена"),
),
patch("local_transcriber.cli.write_transcript", write),
):
out = runner.invoke(app, [str(audio), "--diarize"])
assert out.exit_code == 1
transcribe_file.assert_not_called()
write.assert_not_called()
@pytest.mark.parametrize(
("intervals", "warning"),
[
([SpeakerInterval(0.0, 1.0, 1)], "только один голосовой кластер"),
([], "не нашёл интервалов"),
],
)
def test_cli_unsuccessful_diarization_shape_writes_plain_text_and_exits_nonzero(
tmp_path, intervals, warning
):
audio = tmp_path / "meeting.mp3"
audio.write_bytes(b"fake")
result = _make_result(
segments=[Segment(0.0, 1.0, "Раз два")],
duration=10.0,
)
result.words = [Word(0.0, 0.5, "Раз"), Word(0.5, 1.0, "два")]
model = _make_model()
backend = _make_backend()
backend.word_timestamps_available = True
tfr = _make_tfr(result=result, model=model, backend=backend)
diarizer = MagicMock()
diarizer.process.return_value = DiarizationRun(intervals, elapsed_seconds=0.1)
write = MagicMock()
with (
patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", return_value=audio),
patch("local_transcriber.cli.detect_device", return_value="cpu"),
patch(
"local_transcriber.cli.load_model",
return_value=(model, "cpu", backend, "/models/medium"),
),
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch(
"local_transcriber.cli.load_speaker_diarizer",
return_value=diarizer,
),
patch("local_transcriber.cli.write_transcript", write),
):
out = runner.invoke(app, [str(audio), "--diarize"])
assert out.exit_code == 1
content = write.call_args.args[0]
assert warning in content
assert "[00:00.00 - 00:01.00] Раз два" in content
def test_cli_rejects_nonpositive_speaker_count(tmp_path):
audio = tmp_path / "meeting.mp3"
audio.write_bytes(b"fake")
out = runner.invoke(app, [str(audio), "--speakers", "0"])
assert out.exit_code == 2
def test_cli_verbose_passes_on_segment_callback(tmp_path): def test_cli_verbose_passes_on_segment_callback(tmp_path):
audio = tmp_path / "test.mp3" audio = tmp_path / "test.mp3"
audio.write_bytes(b"fake") audio.write_bytes(b"fake")
@@ -171,7 +464,10 @@ def test_cli_verbose_passes_on_segment_callback(tmp_path):
patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", return_value=audio), patch("local_transcriber.cli.validate_input_file", return_value=audio),
patch("local_transcriber.cli.detect_device", return_value="cpu"), patch("local_transcriber.cli.detect_device", return_value="cpu"),
patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), patch(
"local_transcriber.cli.load_model",
return_value=(model, "cpu", backend, "/models/medium"),
),
patch("local_transcriber.cli._transcribe_file", mock_transcribe_file), patch("local_transcriber.cli._transcribe_file", mock_transcribe_file),
patch("local_transcriber.cli.write_transcript"), patch("local_transcriber.cli.write_transcript"),
): ):
@@ -209,7 +505,10 @@ def test_cli_default_output_path(tmp_path):
patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", return_value=audio), patch("local_transcriber.cli.validate_input_file", return_value=audio),
patch("local_transcriber.cli.detect_device", return_value="cpu"), patch("local_transcriber.cli.detect_device", return_value="cpu"),
patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), patch(
"local_transcriber.cli.load_model",
return_value=(model, "cpu", backend, "/models/medium"),
),
patch("local_transcriber.cli._transcribe_file", return_value=tfr), patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch("local_transcriber.cli.write_transcript", mock_write), patch("local_transcriber.cli.write_transcript", mock_write),
): ):
@@ -234,7 +533,10 @@ def test_cli_custom_output_path(tmp_path):
patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", return_value=audio), patch("local_transcriber.cli.validate_input_file", return_value=audio),
patch("local_transcriber.cli.detect_device", return_value="cpu"), patch("local_transcriber.cli.detect_device", return_value="cpu"),
patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), patch(
"local_transcriber.cli.load_model",
return_value=(model, "cpu", backend, "/models/medium"),
),
patch("local_transcriber.cli._transcribe_file", return_value=tfr), patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch("local_transcriber.cli.write_transcript", mock_write), patch("local_transcriber.cli.write_transcript", mock_write),
): ):
@@ -257,7 +559,10 @@ def test_cli_passes_status_callback_to_transcribe(tmp_path):
patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", return_value=audio), patch("local_transcriber.cli.validate_input_file", return_value=audio),
patch("local_transcriber.cli.detect_device", return_value="cpu"), patch("local_transcriber.cli.detect_device", return_value="cpu"),
patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), patch(
"local_transcriber.cli.load_model",
return_value=(model, "cpu", backend, "/models/medium"),
),
patch("local_transcriber.cli._transcribe_file", mock_transcribe_file), patch("local_transcriber.cli._transcribe_file", mock_transcribe_file),
patch("local_transcriber.cli.write_transcript"), patch("local_transcriber.cli.write_transcript"),
): ):
@@ -276,7 +581,9 @@ def test_cli_load_model_called_with_model_name(tmp_path):
model = _make_model() model = _make_model()
backend = _make_backend() backend = _make_backend()
tfr = _make_tfr(result=result, model=model, backend=backend) tfr = _make_tfr(result=result, model=model, backend=backend)
mock_load_model = MagicMock(return_value=(model, "cpu", backend, "/models/large-v3")) mock_load_model = MagicMock(
return_value=(model, "cpu", backend, "/models/large-v3")
)
with ( with (
patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.load_config", return_value={}),
@@ -302,8 +609,14 @@ def test_cli_windows_cuda_diagnostic(tmp_path):
patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", return_value=audio), patch("local_transcriber.cli.validate_input_file", return_value=audio),
patch("local_transcriber.cli.detect_device", return_value="cuda"), patch("local_transcriber.cli.detect_device", return_value="cuda"),
patch("local_transcriber.cli.load_model", return_value=(model, "cuda", backend, "/models/medium")), patch(
patch("local_transcriber.cli._transcribe_file", side_effect=RuntimeError("CUDA error: no device")), "local_transcriber.cli.load_model",
return_value=(model, "cuda", backend, "/models/medium"),
),
patch(
"local_transcriber.cli._transcribe_file",
side_effect=RuntimeError("CUDA error: no device"),
),
patch("local_transcriber.cli.sys") as mock_sys, patch("local_transcriber.cli.sys") as mock_sys,
): ):
mock_sys.platform = "win32" mock_sys.platform = "win32"
@@ -325,8 +638,14 @@ def test_cli_linux_cuda_error_no_windows_hint(tmp_path):
patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", return_value=audio), patch("local_transcriber.cli.validate_input_file", return_value=audio),
patch("local_transcriber.cli.detect_device", return_value="cuda"), patch("local_transcriber.cli.detect_device", return_value="cuda"),
patch("local_transcriber.cli.load_model", return_value=(model, "cuda", backend, "/models/medium")), patch(
patch("local_transcriber.cli._transcribe_file", side_effect=RuntimeError("CUDA error: no device")), "local_transcriber.cli.load_model",
return_value=(model, "cuda", backend, "/models/medium"),
),
patch(
"local_transcriber.cli._transcribe_file",
side_effect=RuntimeError("CUDA error: no device"),
),
patch("local_transcriber.cli.sys") as mock_sys, patch("local_transcriber.cli.sys") as mock_sys,
): ):
mock_sys.platform = "linux" mock_sys.platform = "linux"
@@ -349,7 +668,10 @@ def test_cli_device_fallback_warning(tmp_path):
patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", return_value=audio), patch("local_transcriber.cli.validate_input_file", return_value=audio),
patch("local_transcriber.cli.detect_device", return_value="cuda"), patch("local_transcriber.cli.detect_device", return_value="cuda"),
patch("local_transcriber.cli.load_model", return_value=(model, "cuda", backend, "/models/medium")), patch(
"local_transcriber.cli.load_model",
return_value=(model, "cuda", backend, "/models/medium"),
),
patch("local_transcriber.cli._transcribe_file", return_value=tfr), patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch("local_transcriber.cli.write_transcript"), patch("local_transcriber.cli.write_transcript"),
): ):
@@ -372,7 +694,10 @@ def test_cli_strict_device_passed_to_transcribe(tmp_path):
patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", return_value=audio), patch("local_transcriber.cli.validate_input_file", return_value=audio),
patch("local_transcriber.cli.detect_device", return_value="cuda"), patch("local_transcriber.cli.detect_device", return_value="cuda"),
patch("local_transcriber.cli.load_model", return_value=(model, "cuda", backend, "/models/medium")), patch(
"local_transcriber.cli.load_model",
return_value=(model, "cuda", backend, "/models/medium"),
),
patch("local_transcriber.cli._transcribe_file", mock_transcribe_file), patch("local_transcriber.cli._transcribe_file", mock_transcribe_file),
patch("local_transcriber.cli.write_transcript"), patch("local_transcriber.cli.write_transcript"),
patch("local_transcriber.cli.get_gpu_name", return_value="RTX 3060"), patch("local_transcriber.cli.get_gpu_name", return_value="RTX 3060"),
@@ -390,7 +715,10 @@ def test_cli_strict_device_passed_to_transcribe(tmp_path):
patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", return_value=audio), patch("local_transcriber.cli.validate_input_file", return_value=audio),
patch("local_transcriber.cli.detect_device", return_value="cpu"), patch("local_transcriber.cli.detect_device", return_value="cpu"),
patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), patch(
"local_transcriber.cli.load_model",
return_value=(model, "cpu", backend, "/models/medium"),
),
patch("local_transcriber.cli._transcribe_file", mock_transcribe_file), patch("local_transcriber.cli._transcribe_file", mock_transcribe_file),
patch("local_transcriber.cli.write_transcript"), patch("local_transcriber.cli.write_transcript"),
): ):
@@ -410,7 +738,10 @@ def test_cli_keyboard_interrupt(tmp_path):
patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", return_value=audio), patch("local_transcriber.cli.validate_input_file", return_value=audio),
patch("local_transcriber.cli.detect_device", return_value="cpu"), patch("local_transcriber.cli.detect_device", return_value="cpu"),
patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), patch(
"local_transcriber.cli.load_model",
return_value=(model, "cpu", backend, "/models/medium"),
),
patch("local_transcriber.cli._transcribe_file", side_effect=KeyboardInterrupt), patch("local_transcriber.cli._transcribe_file", side_effect=KeyboardInterrupt),
patch("local_transcriber.cli.write_transcript"), patch("local_transcriber.cli.write_transcript"),
): ):
@@ -443,8 +774,14 @@ def test_cli_unexpected_error_verbose_traceback(tmp_path):
patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", return_value=audio), patch("local_transcriber.cli.validate_input_file", return_value=audio),
patch("local_transcriber.cli.detect_device", return_value="cpu"), patch("local_transcriber.cli.detect_device", return_value="cpu"),
patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), patch(
patch("local_transcriber.cli._transcribe_file", side_effect=RuntimeError("unexpected boom")), "local_transcriber.cli.load_model",
return_value=(model, "cpu", backend, "/models/medium"),
),
patch(
"local_transcriber.cli._transcribe_file",
side_effect=RuntimeError("unexpected boom"),
),
patch("local_transcriber.cli.write_transcript"), patch("local_transcriber.cli.write_transcript"),
): ):
out = runner.invoke(app, [str(audio), "--verbose"]) out = runner.invoke(app, [str(audio), "--verbose"])
@@ -464,8 +801,14 @@ def test_cli_unexpected_error_no_verbose_hint(tmp_path):
patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", return_value=audio), patch("local_transcriber.cli.validate_input_file", return_value=audio),
patch("local_transcriber.cli.detect_device", return_value="cpu"), patch("local_transcriber.cli.detect_device", return_value="cpu"),
patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), patch(
patch("local_transcriber.cli._transcribe_file", side_effect=RuntimeError("unexpected boom")), "local_transcriber.cli.load_model",
return_value=(model, "cpu", backend, "/models/medium"),
),
patch(
"local_transcriber.cli._transcribe_file",
side_effect=RuntimeError("unexpected boom"),
),
patch("local_transcriber.cli.write_transcript"), patch("local_transcriber.cli.write_transcript"),
): ):
out = runner.invoke(app, [str(audio)]) out = runner.invoke(app, [str(audio)])
@@ -493,7 +836,10 @@ def test_cli_batch_two_files(tmp_path):
patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p), patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
patch("local_transcriber.cli.detect_device", return_value="cpu"), patch("local_transcriber.cli.detect_device", return_value="cpu"),
patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), patch(
"local_transcriber.cli.load_model",
return_value=(model, "cpu", backend, "/models/medium"),
),
patch("local_transcriber.cli._transcribe_file", return_value=tfr), patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch("local_transcriber.cli.write_transcript"), patch("local_transcriber.cli.write_transcript"),
): ):
@@ -503,6 +849,111 @@ def test_cli_batch_two_files(tmp_path):
assert "2 обработано" in out.output assert "2 обработано" in out.output
def test_cli_batch_reuses_one_diarizer_for_all_nonempty_files(tmp_path):
first = tmp_path / "first.mp3"
second = tmp_path / "second.mp3"
first.write_bytes(b"fake")
second.write_bytes(b"fake")
result = _make_result(
segments=[Segment(0.0, 1.0, "Раз два")],
duration=10.0,
)
result.words = [Word(0.0, 0.5, "Раз"), Word(0.5, 1.0, "два")]
model = _make_model()
backend = _make_backend()
backend.word_timestamps_available = True
tfr = _make_tfr(result=result, model=model, backend=backend)
diarizer = MagicMock()
diarizer.process.return_value = DiarizationRun(
intervals=[
SpeakerInterval(0.0, 0.5, 1),
SpeakerInterval(0.5, 1.0, 2),
],
elapsed_seconds=0.1,
)
with (
patch("local_transcriber.cli.load_config", return_value={}),
patch(
"local_transcriber.cli.validate_input_file",
side_effect=lambda path: path,
),
patch("local_transcriber.cli.detect_device", return_value="cpu"),
patch(
"local_transcriber.cli.load_model",
return_value=(model, "cpu", backend, "/models/medium"),
),
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch(
"local_transcriber.cli.load_speaker_diarizer",
return_value=diarizer,
) as load_diarizer,
patch("local_transcriber.cli.write_transcript") as write,
):
out = runner.invoke(app, [str(first), str(second), "--diarize"])
assert out.exit_code == 0
load_diarizer.assert_called_once()
assert [call.args[0] for call in diarizer.process.call_args_list] == [
first,
second,
]
assert write.call_count == 2
def test_cli_batch_continues_after_diarization_error_and_exits_nonzero(tmp_path):
first = tmp_path / "first.mp3"
second = tmp_path / "second.mp3"
first.write_bytes(b"fake")
second.write_bytes(b"fake")
result = _make_result(
segments=[Segment(0.0, 1.0, "Раз два")],
duration=10.0,
)
result.words = [Word(0.0, 0.5, "Раз"), Word(0.5, 1.0, "два")]
model = _make_model()
backend = _make_backend()
backend.word_timestamps_available = True
tfr = _make_tfr(result=result, model=model, backend=backend)
diarizer = MagicMock()
diarizer.process.side_effect = [
RuntimeError("boom"),
DiarizationRun(
[
SpeakerInterval(0.0, 0.5, 1),
SpeakerInterval(0.5, 1.0, 2),
],
elapsed_seconds=0.1,
),
]
with (
patch("local_transcriber.cli.load_config", return_value={}),
patch(
"local_transcriber.cli.validate_input_file",
side_effect=lambda path: path,
),
patch("local_transcriber.cli.detect_device", return_value="cpu"),
patch(
"local_transcriber.cli.load_model",
return_value=(model, "cpu", backend, "/models/medium"),
),
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch(
"local_transcriber.cli.load_speaker_diarizer",
return_value=diarizer,
),
patch("local_transcriber.cli.write_transcript") as write,
):
out = runner.invoke(app, [str(first), str(second), "--diarize"])
assert out.exit_code == 1
assert write.call_count == 2
assert "Диаризация завершилась с ошибкой: boom" in write.call_args_list[0].args[0]
assert "Speaker 1" in write.call_args_list[1].args[0]
assert "1 с деградацией" in out.output
def test_cli_batch_skips_existing(tmp_path): def test_cli_batch_skips_existing(tmp_path):
a = tmp_path / "a.mp3" a = tmp_path / "a.mp3"
b = tmp_path / "b.mp3" b = tmp_path / "b.mp3"
@@ -519,7 +970,10 @@ def test_cli_batch_skips_existing(tmp_path):
patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p), patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
patch("local_transcriber.cli.detect_device", return_value="cpu"), patch("local_transcriber.cli.detect_device", return_value="cpu"),
patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), patch(
"local_transcriber.cli.load_model",
return_value=(model, "cpu", backend, "/models/medium"),
),
patch("local_transcriber.cli._transcribe_file", return_value=tfr), patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch("local_transcriber.cli.write_transcript"), patch("local_transcriber.cli.write_transcript"),
): ):
@@ -540,16 +994,22 @@ def test_cli_batch_all_skipped_no_model_load(tmp_path):
(tmp_path / "b-transcript.md").write_text("existing") (tmp_path / "b-transcript.md").write_text("existing")
mock_load_model = MagicMock() mock_load_model = MagicMock()
mock_load_diarizer = MagicMock()
with ( with (
patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p), patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
patch("local_transcriber.cli.load_model", mock_load_model), patch("local_transcriber.cli.load_model", mock_load_model),
patch(
"local_transcriber.cli.load_speaker_diarizer",
mock_load_diarizer,
),
): ):
out = runner.invoke(app, [str(a), str(b)]) out = runner.invoke(app, [str(a), str(b), "--diarize"])
assert out.exit_code == 0 assert out.exit_code == 0
mock_load_model.assert_not_called() mock_load_model.assert_not_called()
mock_load_diarizer.assert_not_called()
def test_cli_batch_force_overwrites(tmp_path): def test_cli_batch_force_overwrites(tmp_path):
@@ -568,7 +1028,10 @@ def test_cli_batch_force_overwrites(tmp_path):
patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p), patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
patch("local_transcriber.cli.detect_device", return_value="cpu"), patch("local_transcriber.cli.detect_device", return_value="cpu"),
patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), patch(
"local_transcriber.cli.load_model",
return_value=(model, "cpu", backend, "/models/medium"),
),
patch("local_transcriber.cli._transcribe_file", return_value=tfr), patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch("local_transcriber.cli.write_transcript"), patch("local_transcriber.cli.write_transcript"),
): ):
@@ -602,8 +1065,13 @@ def test_cli_batch_per_file_error(tmp_path):
patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p), patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
patch("local_transcriber.cli.detect_device", return_value="cpu"), patch("local_transcriber.cli.detect_device", return_value="cpu"),
patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), patch(
patch("local_transcriber.cli._transcribe_file", side_effect=transcribe_side_effect), "local_transcriber.cli.load_model",
return_value=(model, "cpu", backend, "/models/medium"),
),
patch(
"local_transcriber.cli._transcribe_file", side_effect=transcribe_side_effect
),
patch("local_transcriber.cli.write_transcript"), patch("local_transcriber.cli.write_transcript"),
): ):
out = runner.invoke(app, [str(a), str(b)]) out = runner.invoke(app, [str(a), str(b)])
@@ -631,9 +1099,15 @@ def test_cli_batch_invalid_in_prescan(tmp_path):
with ( with (
patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", side_effect=validate_side_effect), patch(
"local_transcriber.cli.validate_input_file",
side_effect=validate_side_effect,
),
patch("local_transcriber.cli.detect_device", return_value="cpu"), patch("local_transcriber.cli.detect_device", return_value="cpu"),
patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), patch(
"local_transcriber.cli.load_model",
return_value=(model, "cpu", backend, "/models/medium"),
),
patch("local_transcriber.cli._transcribe_file", return_value=tfr), patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch("local_transcriber.cli.write_transcript"), patch("local_transcriber.cli.write_transcript"),
): ):
@@ -768,7 +1242,10 @@ def test_cli_batch_fallback_warning(tmp_path):
patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p), patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
patch("local_transcriber.cli.detect_device", return_value="cuda"), patch("local_transcriber.cli.detect_device", return_value="cuda"),
patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), patch(
"local_transcriber.cli.load_model",
return_value=(model, "cpu", backend, "/models/medium"),
),
patch("local_transcriber.cli._transcribe_file", return_value=tfr), patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch("local_transcriber.cli.write_transcript"), patch("local_transcriber.cli.write_transcript"),
): ):
@@ -795,8 +1272,13 @@ def test_cli_batch_empty_speech_warning(tmp_path):
patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p), patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
patch("local_transcriber.cli.detect_device", return_value="cpu"), patch("local_transcriber.cli.detect_device", return_value="cpu"),
patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), patch(
patch("local_transcriber.cli._transcribe_file", side_effect=[tfr_empty, tfr_ok]), "local_transcriber.cli.load_model",
return_value=(model, "cpu", backend, "/models/medium"),
),
patch(
"local_transcriber.cli._transcribe_file", side_effect=[tfr_empty, tfr_ok]
),
patch("local_transcriber.cli.write_transcript"), patch("local_transcriber.cli.write_transcript"),
): ):
out = runner.invoke(app, [str(a), str(b)]) out = runner.invoke(app, [str(a), str(b)])
@@ -817,15 +1299,24 @@ def test_cli_batch_midstream_fallback_warning(tmp_path):
model_cpu = _make_model() model_cpu = _make_model()
backend = _make_backend() backend = _make_backend()
result = _make_result(device_used="cpu") result = _make_result(device_used="cpu")
tfr_fallback = _make_tfr(result=result, model=model_cpu, actual_device="cpu", backend=backend) tfr_fallback = _make_tfr(
tfr_ok = _make_tfr(result=result, model=model_cpu, actual_device="cpu", backend=backend) result=result, model=model_cpu, actual_device="cpu", backend=backend
)
tfr_ok = _make_tfr(
result=result, model=model_cpu, actual_device="cpu", backend=backend
)
with ( with (
patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p), patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
patch("local_transcriber.cli.detect_device", return_value="cuda"), patch("local_transcriber.cli.detect_device", return_value="cuda"),
patch("local_transcriber.cli.load_model", return_value=(model_gpu, "cuda", backend, "/models/medium")), patch(
patch("local_transcriber.cli._transcribe_file", side_effect=[tfr_fallback, tfr_ok]), "local_transcriber.cli.load_model",
return_value=(model_gpu, "cuda", backend, "/models/medium"),
),
patch(
"local_transcriber.cli._transcribe_file", side_effect=[tfr_fallback, tfr_ok]
),
patch("local_transcriber.cli.write_transcript"), patch("local_transcriber.cli.write_transcript"),
): ):
out = runner.invoke(app, [str(a), str(b)]) out = runner.invoke(app, [str(a), str(b)])
@@ -864,8 +1355,14 @@ def test_cli_batch_model_loaded_once(tmp_path):
def test_format_device_info_openvino_gpu(): def test_format_device_info_openvino_gpu():
with patch("local_transcriber.cli.get_intel_gpu_name", return_value="Intel(R) Arc(TM) 140T GPU"): with patch(
assert _format_device_info("openvino-gpu") == "OpenVINO (Intel(R) Arc(TM) 140T GPU)" "local_transcriber.cli.get_intel_gpu_name",
return_value="Intel(R) Arc(TM) 140T GPU",
):
assert (
_format_device_info("openvino-gpu")
== "OpenVINO (Intel(R) Arc(TM) 140T GPU)"
)
def test_format_device_info_openvino_gpu_no_name(): def test_format_device_info_openvino_gpu_no_name():
@@ -900,9 +1397,13 @@ def test_cli_openvino_gpu_happy_path(tmp_path):
audio.write_bytes(b"fake") audio.write_bytes(b"fake")
result = _make_result(device_used="openvino-gpu") result = _make_result(device_used="openvino-gpu")
patches = _single_patches(result=result, tmp_file=audio, actual_device="openvino-gpu") patches = _single_patches(
result=result, tmp_file=audio, actual_device="openvino-gpu"
)
with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5]: with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5]:
with patch("local_transcriber.cli.get_intel_gpu_name", return_value="Intel Arc 140T"): with patch(
"local_transcriber.cli.get_intel_gpu_name", return_value="Intel Arc 140T"
):
out = runner.invoke(app, [str(audio), "--device", "openvino-gpu"]) out = runner.invoke(app, [str(audio), "--device", "openvino-gpu"])
assert out.exit_code == 0 assert out.exit_code == 0
@@ -915,8 +1416,12 @@ def test_cli_openvino_alias_resolves_to_gpu(tmp_path):
result = _make_result(device_used="openvino-gpu") result = _make_result(device_used="openvino-gpu")
model = _make_model() model = _make_model()
backend = _make_backend() backend = _make_backend()
tfr = _make_tfr(result=result, model=model, actual_device="openvino-gpu", backend=backend) tfr = _make_tfr(
mock_load_model = MagicMock(return_value=(model, "openvino-gpu", backend, "/models/medium")) result=result, model=model, actual_device="openvino-gpu", backend=backend
)
mock_load_model = MagicMock(
return_value=(model, "openvino-gpu", backend, "/models/medium")
)
with ( with (
patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.load_config", return_value={}),
@@ -925,7 +1430,9 @@ def test_cli_openvino_alias_resolves_to_gpu(tmp_path):
patch("local_transcriber.cli.load_model", mock_load_model), patch("local_transcriber.cli.load_model", mock_load_model),
patch("local_transcriber.cli._transcribe_file", return_value=tfr), patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch("local_transcriber.cli.write_transcript"), patch("local_transcriber.cli.write_transcript"),
patch("local_transcriber.cli.get_intel_gpu_name", return_value="Intel Arc 140T"), patch(
"local_transcriber.cli.get_intel_gpu_name", return_value="Intel Arc 140T"
),
): ):
out = runner.invoke(app, [str(audio), "--device", "openvino"]) out = runner.invoke(app, [str(audio), "--device", "openvino"])
@@ -1000,7 +1507,9 @@ def test_cli_install_menu_success(tmp_path):
cmd_path = tmp_path / "Transcribe.cmd" cmd_path = tmp_path / "Transcribe.cmd"
with ( with (
patch("local_transcriber.cli.install_context_menu", return_value=cmd_path) as mock_install, patch(
"local_transcriber.cli.install_context_menu", return_value=cmd_path
) as mock_install,
patch("local_transcriber.cli.load_config") as mock_load_config, patch("local_transcriber.cli.load_config") as mock_load_config,
patch("local_transcriber.cli.sys") as mock_sys, patch("local_transcriber.cli.sys") as mock_sys,
): ):
@@ -1018,7 +1527,9 @@ def test_cli_uninstall_menu_success(tmp_path):
cmd_path = tmp_path / "Transcribe.cmd" cmd_path = tmp_path / "Transcribe.cmd"
with ( with (
patch("local_transcriber.cli.uninstall_context_menu", return_value=cmd_path) as mock_uninstall, patch(
"local_transcriber.cli.uninstall_context_menu", return_value=cmd_path
) as mock_uninstall,
patch("local_transcriber.cli.load_config") as mock_load_config, patch("local_transcriber.cli.load_config") as mock_load_config,
patch("local_transcriber.cli.sys") as mock_sys, patch("local_transcriber.cli.sys") as mock_sys,
): ):
@@ -1079,7 +1590,10 @@ def test_cli_menu_flags_available_only_on_windows():
def test_cli_menu_runtime_error_has_no_verbose_hint(): def test_cli_menu_runtime_error_has_no_verbose_hint():
with ( with (
patch("local_transcriber.cli.install_context_menu", side_effect=RuntimeError("нет APPDATA")), patch(
"local_transcriber.cli.install_context_menu",
side_effect=RuntimeError("нет APPDATA"),
),
patch("local_transcriber.cli.sys") as mock_sys, patch("local_transcriber.cli.sys") as mock_sys,
): ):
mock_sys.platform = "win32" mock_sys.platform = "win32"
@@ -1169,7 +1683,10 @@ def test_cli_quality_warning_batch_includes_file_name(tmp_path):
patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p), patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
patch("local_transcriber.cli.detect_device", return_value="cpu"), patch("local_transcriber.cli.detect_device", return_value="cpu"),
patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), patch(
"local_transcriber.cli.load_model",
return_value=(model, "cpu", backend, "/models/medium"),
),
patch("local_transcriber.cli._transcribe_file", side_effect=[tfr_warn, tfr_ok]), patch("local_transcriber.cli._transcribe_file", side_effect=[tfr_warn, tfr_ok]),
patch("local_transcriber.cli.write_transcript"), patch("local_transcriber.cli.write_transcript"),
patch("local_transcriber.cli.console", Console(stderr=True, width=1000)), patch("local_transcriber.cli.console", Console(stderr=True, width=1000)),
@@ -1178,8 +1695,7 @@ def test_cli_quality_warning_batch_includes_file_name(tmp_path):
assert out.exit_code == 0 assert out.exit_code == 0
assert ( assert (
" a.mp3: транскрипт покрывает 01:00 из 10:00 — " " a.mp3: транскрипт покрывает 01:00 из 10:00 — возможна потеря хвоста записи"
"возможна потеря хвоста записи"
) in out.output ) in out.output
+1 -1
View File
@@ -125,4 +125,4 @@ def test_get_transcribe_exe_requires_existing_exe(tmp_path, monkeypatch):
monkeypatch.setattr(context_menu.sys, "executable", str(python_exe)) monkeypatch.setattr(context_menu.sys, "executable", str(python_exe))
with pytest.raises(RuntimeError, match="uv sync"): with pytest.raises(RuntimeError, match="uv sync"):
context_menu.get_transcribe_exe() context_menu.get_transcribe_exe()
+120
View File
@@ -0,0 +1,120 @@
from local_transcriber.diarization import build_speaker_transcript
from local_transcriber.types import (
SmallSpeakerCluster,
SpeakerInterval,
SpeakerTurn,
Word,
)
def test_build_speaker_transcript_assigns_and_groups_words():
words = [
Word(start=0.0, end=0.8, text="Добрый"),
Word(start=0.8, end=1.4, text="день."),
Word(start=1.5, end=2.1, text="Привет!"),
]
intervals = [
SpeakerInterval(start=0.0, end=1.4, cluster=7),
SpeakerInterval(start=1.4, end=2.3, cluster=3),
]
transcript = build_speaker_transcript(words, intervals, recording_duration=30.0)
assert [
(turn.speaker, turn.start, turn.end, turn.text) for turn in transcript.turns
] == [
(1, 0.0, 1.4, "Добрый день."),
(2, 1.5, 2.1, "Привет!"),
]
assert transcript.cluster_count == 2
assert transcript.unassigned_word_count == 0
def test_build_speaker_transcript_reports_small_cluster_without_filtering_it():
words = [
Word(start=0.0, end=1.0, text="Редкая реплика."),
Word(start=5.0, end=6.0, text="Основная реплика."),
]
intervals = [
SpeakerInterval(start=0.0, end=4.9, cluster=4),
SpeakerInterval(start=5.0, end=10.0, cluster=9),
]
transcript = build_speaker_transcript(words, intervals, recording_duration=100.0)
assert [turn.speaker for turn in transcript.turns] == [1, 2]
assert transcript.small_clusters == [SmallSpeakerCluster(speaker=1, duration=4.9)]
def test_build_speaker_transcript_keeps_equal_overlap_unassigned():
words = [Word(start=0.0, end=1.0, text="Спорное слово")]
intervals = [
SpeakerInterval(start=0.0, end=0.1, cluster=8),
SpeakerInterval(start=0.3, end=0.5, cluster=8),
SpeakerInterval(start=0.0, end=0.3, cluster=2),
]
transcript = build_speaker_transcript(words, intervals, recording_duration=10.0)
assert transcript.turns[0].speaker is None
assert transcript.unassigned_word_count == 1
def test_build_speaker_transcript_keeps_word_without_overlap_unknown():
transcript = build_speaker_transcript(
[Word(start=5.0, end=6.0, text="Вне разметки")],
[SpeakerInterval(start=0.0, end=1.0, cluster=1)],
recording_duration=10.0,
)
assert transcript.turns == [SpeakerTurn(5.0, 6.0, "Вне разметки", None)]
assert transcript.unassigned_word_count == 1
def test_build_speaker_transcript_splits_at_two_second_pause():
transcript = build_speaker_transcript(
[
Word(0.0, 1.0, "До паузы."),
Word(3.0, 4.0, "После паузы."),
],
[SpeakerInterval(0.0, 4.0, 1)],
recording_duration=10.0,
)
assert [turn.text for turn in transcript.turns] == [
"До паузы.",
"После паузы.",
]
def test_build_speaker_transcript_does_not_exceed_sixty_seconds():
transcript = build_speaker_transcript(
[
Word(0.0, 30.0, "Начало."),
Word(30.0, 60.0, "Продолжение."),
Word(60.0, 61.0, "Новая реплика."),
],
[SpeakerInterval(0.0, 61.0, 1)],
recording_duration=70.0,
)
assert [turn.text for turn in transcript.turns] == [
"Начало. Продолжение.",
"Новая реплика.",
]
def test_build_speaker_transcript_preserves_punctuation_without_leading_space():
transcript = build_speaker_transcript(
[
Word(0.0, 0.4, "Тарадата"),
Word(0.4, 0.5, "+"),
Word(0.5, 0.7, "Click"),
Word(0.7, 0.8, ""),
Word(0.8, 1.0, "это"),
],
[SpeakerInterval(0.0, 1.0, 1)],
recording_duration=10.0,
)
assert transcript.turns[0].text == "Тарадата+ Click— это"
+118 -2
View File
@@ -1,5 +1,4 @@
from datetime import datetime from datetime import datetime
from pathlib import Path
from local_transcriber.formatter import ( from local_transcriber.formatter import (
LANGUAGE_DETECTED, LANGUAGE_DETECTED,
@@ -11,7 +10,12 @@ from local_transcriber.formatter import (
write_transcript, write_transcript,
) )
from local_transcriber.transcriber import Segment, TranscribeResult from local_transcriber.transcriber import Segment, TranscribeResult
from local_transcriber.types import UNKNOWN_LANGUAGE from local_transcriber.types import (
UNKNOWN_LANGUAGE,
SmallSpeakerCluster,
SpeakerTranscript,
SpeakerTurn,
)
def test_format_timestamp_minutes(): def test_format_timestamp_minutes():
@@ -59,6 +63,118 @@ def test_format_transcript_basic():
assert "[00:00.00 - 00:09.15] Добрый день, коллеги. Первый вопрос." in content assert "[00:00.00 - 00:09.15] Добрый день, коллеги. Первый вопрос." in content
def test_format_transcript_speaker_turns_use_truncated_start_timestamps():
result = TranscribeResult(
segments=[Segment(start=547.96, end=560.0, text=" Обычный текст")],
language="ru",
language_probability=0.97,
duration=700.0,
device_used="cpu",
)
speakers = SpeakerTranscript(
turns=[
SpeakerTurn(547.96, 550.0, "Первая реплика.", 1),
SpeakerTurn(558.4, 560.0, "Ответ.", 2),
],
cluster_count=2,
unassigned_word_count=0,
small_clusters=[],
)
content = format_transcript(
result,
source_filename="meeting.mp4",
model_name="medium",
device_info="CPU",
language_mode=LANGUAGE_DETECTED,
speaker_transcript=speakers,
)
assert "- **Голосовых кластеров**: 2" in content
assert "[09:07] Speaker 1: Первая реплика." in content
assert "[09:18] Speaker 2: Ответ." in content
assert "[09:07.96 -" not in content
def test_format_transcript_speaker_turns_use_hours_after_one_hour():
result = TranscribeResult(
segments=[Segment(start=3661.9, end=3663.0, text=" Длинная встреча")],
language="ru",
language_probability=1.0,
duration=3700.0,
device_used="cpu",
)
speakers = SpeakerTranscript(
turns=[SpeakerTurn(3661.9, 3663.0, "Длинная встреча", 1)],
cluster_count=2,
unassigned_word_count=0,
small_clusters=[],
)
content = format_transcript(
result,
source_filename="meeting.mp4",
model_name="medium",
device_info="CPU",
language_mode=LANGUAGE_FORCED,
speaker_transcript=speakers,
)
assert "[01:01:01] Speaker 1: Длинная встреча" in content
def test_format_transcript_reports_unknown_words_and_small_clusters():
result = TranscribeResult(
segments=[Segment(start=0.0, end=8.0, text=" Текст")],
language="ru",
language_probability=1.0,
duration=20.0,
device_used="cpu",
)
speakers = SpeakerTranscript(
turns=[SpeakerTurn(1.2, 2.0, "Неясная реплика.", None)],
cluster_count=2,
unassigned_word_count=3,
small_clusters=[SmallSpeakerCluster(speaker=2, duration=4.2)],
)
content = format_transcript(
result,
source_filename="meeting.mp4",
model_name="medium",
device_info="CPU",
language_mode=LANGUAGE_FORCED,
speaker_transcript=speakers,
)
assert "[00:01] Speaker ?: Неясная реплика." in content
assert "3 слов без назначенного говорящего" in content
assert "малый кластер Speaker 2: 4.2 с" in content
def test_format_transcript_keeps_plain_body_with_diarization_warning():
result = TranscribeResult(
segments=[Segment(start=0.0, end=2.0, text=" Полезный текст.")],
language="ru",
language_probability=1.0,
duration=5.0,
device_used="cpu",
)
content = format_transcript(
result,
source_filename="meeting.mp4",
model_name="medium",
device_info="CPU",
language_mode=LANGUAGE_FORCED,
diarization_warning="Диаризация завершилась с ошибкой: boom",
)
assert "**Внимание**: Диаризация завершилась с ошибкой: boom" in content
assert "[00:00.00 - 00:02.00] Полезный текст." in content
assert "Speaker" not in content
def test_format_transcript_unknown_language_without_placeholder(): def test_format_transcript_unknown_language_without_placeholder():
"""Неизвестный язык печатается одной строкой, без служебного значения.""" """Неизвестный язык печатается одной строкой, без служебного значения."""
result = TranscribeResult( result = TranscribeResult(
+166 -9
View File
@@ -5,16 +5,18 @@ import warnings
import pytest import pytest
from local_transcriber.backends.onnx_asr import OnnxAsrBackend from local_transcriber.backends.onnx_asr import OnnxAsrBackend
from local_transcriber.types import UNKNOWN_LANGUAGE, Segment, TranscribeResult from local_transcriber.types import UNKNOWN_LANGUAGE, Segment, TranscribeResult, Word
class FakeVadSegment: class FakeVadSegment:
"""Mimics onnx-asr SegmentResult.""" """Mimics onnx-asr SegmentResult."""
def __init__(self, start, end, text): def __init__(self, start, end, text, tokens=None, timestamps=None):
self.start = start self.start = start
self.end = end self.end = end
self.text = text self.text = text
self.tokens = [f" {text}"] if tokens is None else tokens
self.timestamps = [0.0] if timestamps is None else timestamps
class TestEnsureModelAvailable: class TestEnsureModelAvailable:
@@ -59,6 +61,9 @@ class TestEnsureModelAvailable:
def with_vad(self, vad): def with_vad(self, vad):
return self return self
def with_timestamps(self):
return self
def fake_load_model(*, model, quantization): def fake_load_model(*, model, quantization):
quantizations.append(quantization) quantizations.append(quantization)
return FakeAsrAdapter() return FakeAsrAdapter()
@@ -82,21 +87,45 @@ class TestEnsureModelAvailable:
class TestCreateModel: class TestCreateModel:
def test_wraps_vad_model_with_timestamps(self, monkeypatch):
timestamped_model = object()
class FakeVadAdapter:
def with_timestamps(self):
return timestamped_model
class FakeAsrAdapter:
def with_vad(self, vad):
return FakeVadAdapter()
monkeypatch.setattr("onnx_asr.load_model", lambda **kwargs: FakeAsrAdapter())
monkeypatch.setattr("onnx_asr.load_vad", lambda model: object())
model = OnnxAsrBackend().create_model("gigaam-v3-e2e-rnnt", "onnx", "int8")
assert model is timestamped_model
def test_calls_load_model_with_correct_args(self, monkeypatch): def test_calls_load_model_with_correct_args(self, monkeypatch):
"""Verify create_model passes correct args to onnx_asr.load_model.""" """Verify create_model passes correct args to onnx_asr.load_model."""
calls = [] calls = []
def fake_load_model(model=None, path=None, quantization=None, def fake_load_model(model=None, path=None, quantization=None, **kwargs):
**kwargs): calls.append(
calls.append({ {
"model": model, "path": path, "quantization": quantization, "model": model,
}) "path": path,
"quantization": quantization,
}
)
return FakeAsrAdapter() return FakeAsrAdapter()
class FakeAsrAdapter: class FakeAsrAdapter:
def with_vad(self, vad): def with_vad(self, vad):
return self return self
def with_timestamps(self):
return self
monkeypatch.setattr("onnx_asr.load_model", fake_load_model) monkeypatch.setattr("onnx_asr.load_model", fake_load_model)
backend = OnnxAsrBackend() backend = OnnxAsrBackend()
@@ -123,6 +152,9 @@ class TestCreateModel:
self._vad = vad self._vad = vad
return self return self
def with_timestamps(self):
return self
monkeypatch.setattr("onnx_asr.load_model", fake_load_model) monkeypatch.setattr("onnx_asr.load_model", fake_load_model)
monkeypatch.setattr("onnx_asr.load_vad", fake_load_vad) monkeypatch.setattr("onnx_asr.load_vad", fake_load_vad)
@@ -143,6 +175,9 @@ class TestCreateModel:
def with_vad(self, vad): def with_vad(self, vad):
return self return self
def with_timestamps(self):
return self
monkeypatch.setattr("onnx_asr.load_model", fake_load_model) monkeypatch.setattr("onnx_asr.load_model", fake_load_model)
monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kw: None) monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kw: None)
@@ -167,6 +202,9 @@ class TestCreateModel:
def with_vad(self, vad): def with_vad(self, vad):
return self return self
def with_timestamps(self):
return self
monkeypatch.setattr("onnx_asr.load_model", fake_load_model) monkeypatch.setattr("onnx_asr.load_model", fake_load_model)
monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kw: None) monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kw: None)
@@ -187,6 +225,9 @@ class TestCreateModel:
def with_vad(self, vad): def with_vad(self, vad):
return self return self
def with_timestamps(self):
return self
monkeypatch.setattr("onnx_asr.load_model", fake_load_model) monkeypatch.setattr("onnx_asr.load_model", fake_load_model)
monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kw: None) monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kw: None)
@@ -207,6 +248,9 @@ class TestCreateModel:
def with_vad(self, vad): def with_vad(self, vad):
return self return self
def with_timestamps(self):
return self
monkeypatch.setattr("onnx_asr.load_model", fake_load_model) monkeypatch.setattr("onnx_asr.load_model", fake_load_model)
monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kw: None) monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kw: None)
@@ -298,6 +342,7 @@ class TestTranscribe:
def fake_decode_audio(path, sampling_rate=16000): def fake_decode_audio(path, sampling_rate=16000):
import numpy as np import numpy as np
return np.array(audio_samples, dtype=np.float32) return np.array(audio_samples, dtype=np.float32)
class FakeModel: class FakeModel:
@@ -310,7 +355,9 @@ class TestTranscribe:
backend = OnnxAsrBackend() backend = OnnxAsrBackend()
backend.actual_compute_type = "int8" backend.actual_compute_type = "int8"
result = backend.transcribe( result = backend.transcribe(
FakeModel(), wav_file, language=None, FakeModel(),
wav_file,
language=None,
) )
assert isinstance(result, TranscribeResult) assert isinstance(result, TranscribeResult)
@@ -319,6 +366,110 @@ class TestTranscribe:
assert result.segments[1] == Segment(start=1.0, end=2.5, text="world") assert result.segments[1] == Segment(start=1.0, end=2.5, text="world")
assert result.duration == 1.0 # 16000 samples / 16000 Hz assert result.duration == 1.0 # 16000 samples / 16000 Hz
def test_transcribe_converts_vad_token_timestamps_to_global_words(
self, monkeypatch, tmp_path
):
wav_file = tmp_path / "test.wav"
wav_file.write_bytes(b"fake audio")
monkeypatch.setattr(
"faster_whisper.decode_audio",
lambda path, sampling_rate=16000: [0.0] * 16_000,
)
timestamped_segment = FakeVadSegment(
10.0,
12.0,
"Привет, мир",
tokens=[" ", "П", "р", "и", "в", "е", "т", ",", " ", "м", "и", "р"],
timestamps=[0.0, 0.1, 0.1, 0.1, 0.2, 0.2, 0.3, 0.3, 0.5, 0.6, 0.6, 0.7],
)
class FakeModel:
def recognize(self, waveform, sample_rate, language=None):
yield timestamped_segment
result = OnnxAsrBackend().transcribe(FakeModel(), wav_file, language="ru")
assert result.words == [
Word(start=10.0, end=10.5, text=" Привет,"),
Word(start=10.5, end=12.0, text=" мир"),
]
assert "".join(word.text for word in result.words).strip() == "Привет, мир"
def test_transcribe_rejects_nonempty_segment_without_token_timestamps(
self, monkeypatch, tmp_path
):
wav_file = tmp_path / "test.wav"
wav_file.write_bytes(b"fake audio")
monkeypatch.setattr(
"faster_whisper.decode_audio",
lambda path, sampling_rate=16000: [0.0] * 16_000,
)
segment = FakeVadSegment(0.0, 1.0, "Текст")
segment.tokens = None
segment.timestamps = None
class FakeModel:
def recognize(self, waveform, sample_rate, language=None):
yield segment
with pytest.raises(RuntimeError, match="пословные таймкоды"):
OnnxAsrBackend().transcribe(FakeModel(), wav_file, language="ru")
def test_transcribe_keeps_words_with_equal_emission_timestamps(
self, monkeypatch, tmp_path
):
wav_file = tmp_path / "test.wav"
wav_file.write_bytes(b"fake audio")
monkeypatch.setattr(
"faster_whisper.decode_audio",
lambda path, sampling_rate=16000: [0.0] * 16_000,
)
segment = FakeVadSegment(
10.0,
12.0,
"Да нет потом",
tokens=[" ", "Да", " ", "нет", " ", "потом"],
timestamps=[0.0, 0.0, 0.0, 0.0, 0.5, 0.5],
)
class FakeModel:
def recognize(self, waveform, sample_rate, language=None):
yield segment
result = OnnxAsrBackend().transcribe(FakeModel(), wav_file, language="ru")
assert [word.text for word in result.words] == [" Да", " нет", " потом"]
assert [(word.start, word.end) for word in result.words] == [
(10.0, 10.5),
(10.0, 10.5),
(10.5, 12.0),
]
assert "".join(word.text for word in result.words).strip() == segment.text
def test_transcribe_keeps_word_clamped_to_segment_end(self, monkeypatch, tmp_path):
wav_file = tmp_path / "test.wav"
wav_file.write_bytes(b"fake audio")
monkeypatch.setattr(
"faster_whisper.decode_audio",
lambda path, sampling_rate=16000: [0.0] * 16_000,
)
segment = FakeVadSegment(
10.0,
12.0,
"Позднее",
tokens=[" ", "Позднее"],
timestamps=[2.0, 2.0],
)
class FakeModel:
def recognize(self, waveform, sample_rate, language=None):
yield segment
result = OnnxAsrBackend().transcribe(FakeModel(), wav_file, language="ru")
assert result.words == [Word(start=12.0, end=12.0, text=" Позднее")]
def test_transcribe_calls_on_segment(self, monkeypatch, tmp_path): def test_transcribe_calls_on_segment(self, monkeypatch, tmp_path):
"""Verify on_segment callback is invoked per segment.""" """Verify on_segment callback is invoked per segment."""
wav_file = tmp_path / "test.wav" wav_file = tmp_path / "test.wav"
@@ -326,6 +477,7 @@ class TestTranscribe:
def fake_decode_audio(path, sampling_rate=16000): def fake_decode_audio(path, sampling_rate=16000):
import numpy as np import numpy as np
return np.array([0.0] * 16000, dtype=np.float32) return np.array([0.0] * 16000, dtype=np.float32)
segments_captured = [] segments_captured = []
@@ -339,7 +491,9 @@ class TestTranscribe:
backend = OnnxAsrBackend() backend = OnnxAsrBackend()
backend.transcribe( backend.transcribe(
FakeModel(), wav_file, language=None, FakeModel(),
wav_file,
language=None,
on_segment=lambda s: segments_captured.append(s), on_segment=lambda s: segments_captured.append(s),
) )
@@ -354,6 +508,7 @@ class TestTranscribe:
def fake_decode_audio(path, sampling_rate=16000): def fake_decode_audio(path, sampling_rate=16000):
import numpy as np import numpy as np
return np.array([0.0] * 16000, dtype=np.float32) return np.array([0.0] * 16000, dtype=np.float32)
lang_received = [] lang_received = []
@@ -377,6 +532,7 @@ class TestTranscribe:
def fake_decode_audio(path, sampling_rate=16000): def fake_decode_audio(path, sampling_rate=16000):
import numpy as np import numpy as np
return np.array([0.0] * 16000, dtype=np.float32) return np.array([0.0] * 16000, dtype=np.float32)
class FakeModel: class FakeModel:
@@ -419,6 +575,7 @@ class TestTranscribe:
class TestBackendRegistration: class TestBackendRegistration:
def test_get_backend_returns_onnx_backend(self): def test_get_backend_returns_onnx_backend(self):
from local_transcriber.backends import get_backend from local_transcriber.backends import get_backend
backend = get_backend("onnx") backend = get_backend("onnx")
assert isinstance(backend, OnnxAsrBackend) assert isinstance(backend, OnnxAsrBackend)
+231
View File
@@ -0,0 +1,231 @@
import hashlib
import io
import sys
import tarfile
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
from local_transcriber.speaker_diarizer import SpeakerDiarizer, load_speaker_diarizer
from local_transcriber.types import SpeakerInterval
def test_process_returns_sorted_domain_intervals(tmp_path):
audio = tmp_path / "meeting.mp3"
raw_result = MagicMock()
raw_result.sort_by_start_time.return_value = [
SimpleNamespace(start=0.2, end=1.1, speaker=4),
SimpleNamespace(start=1.3, end=2.0, speaker=2),
]
engine = MagicMock()
engine.process.return_value = raw_result
diarizer = SpeakerDiarizer(engine)
samples = np.zeros(16_000, dtype=np.float32)
with patch("faster_whisper.decode_audio", return_value=samples) as decode:
run = diarizer.process(audio)
assert run.intervals == [
SpeakerInterval(start=0.2, end=1.1, cluster=4),
SpeakerInterval(start=1.3, end=2.0, cluster=2),
]
decode.assert_called_once_with(str(audio), sampling_rate=16_000)
engine.process.assert_called_once_with(samples)
def test_process_reports_engine_progress(tmp_path):
raw_result = MagicMock()
raw_result.sort_by_start_time.return_value = []
engine = MagicMock()
def process(samples, callback):
assert callback(2, 4) == 0
return raw_result
engine.process.side_effect = process
statuses = []
with patch(
"faster_whisper.decode_audio",
return_value=np.zeros(16_000, dtype=np.float32),
):
SpeakerDiarizer(engine).process(
tmp_path / "meeting.mp3",
on_status=statuses.append,
)
assert "Определяю говорящих... 2 / 4" in statuses
def test_load_speaker_diarizer_uses_verified_cache_and_calibrated_config(
tmp_path, monkeypatch
):
segmentation = tmp_path / "pyannote-segmentation-3.0.onnx"
embedding = tmp_path / "wespeaker_en_voxceleb_resnet34_LM.onnx"
segmentation.write_bytes(b"segmentation")
embedding.write_bytes(b"embedding")
monkeypatch.setattr(
"local_transcriber.speaker_diarizer._SEGMENTATION_SHA256",
hashlib.sha256(segmentation.read_bytes()).hexdigest(),
)
monkeypatch.setattr(
"local_transcriber.speaker_diarizer._EMBEDDING_SHA256",
hashlib.sha256(embedding.read_bytes()).hexdigest(),
)
captured = {}
def config_factory(**kwargs):
config = SimpleNamespace(**kwargs, validate=lambda: True)
captured["config"] = config
return config
engine = SimpleNamespace(sample_rate=16_000)
sherpa = SimpleNamespace(
OfflineSpeakerSegmentationPyannoteModelConfig=lambda **kwargs: SimpleNamespace(
**kwargs
),
OfflineSpeakerSegmentationModelConfig=lambda **kwargs: SimpleNamespace(
**kwargs
),
SpeakerEmbeddingExtractorConfig=lambda **kwargs: SimpleNamespace(**kwargs),
FastClusteringConfig=lambda **kwargs: SimpleNamespace(**kwargs),
OfflineSpeakerDiarizationConfig=config_factory,
OfflineSpeakerDiarization=lambda config: engine,
)
with (
patch("huggingface_hub.cached_assets_path", return_value=tmp_path),
patch.dict(sys.modules, {"sherpa_onnx": sherpa}),
patch("httpx.stream", side_effect=AssertionError("network is not expected")),
):
diarizer = load_speaker_diarizer(speakers=None, threads=0)
config = captured["config"]
assert config.clustering.num_clusters == -1
assert config.clustering.threshold == 0.89
assert config.min_duration_on == 0.3
assert config.min_duration_off == 0.5
assert not hasattr(config.segmentation, "num_threads")
assert not hasattr(config.embedding, "num_threads")
assert isinstance(diarizer, SpeakerDiarizer)
def test_load_speaker_diarizer_downloads_and_verifies_missing_models(
tmp_path, monkeypatch
):
segmentation_bytes = b"downloaded segmentation"
embedding_bytes = b"downloaded embedding"
archive_buffer = io.BytesIO()
with tarfile.open(fileobj=archive_buffer, mode="w:bz2") as archive:
member = tarfile.TarInfo("sherpa-onnx-pyannote-segmentation-3-0/model.onnx")
member.size = len(segmentation_bytes)
archive.addfile(member, io.BytesIO(segmentation_bytes))
monkeypatch.setattr(
"local_transcriber.speaker_diarizer._SEGMENTATION_SHA256",
hashlib.sha256(segmentation_bytes).hexdigest(),
)
monkeypatch.setattr(
"local_transcriber.speaker_diarizer._EMBEDDING_SHA256",
hashlib.sha256(embedding_bytes).hexdigest(),
)
class FakeResponse:
def __init__(self, content):
self.content = content
def __enter__(self):
return self
def __exit__(self, *args):
return False
def raise_for_status(self):
return None
def iter_bytes(self):
yield self.content
requested_urls = []
def fake_stream(method, url, **kwargs):
requested_urls.append(url)
content = (
archive_buffer.getvalue() if "segmentation" in url else embedding_bytes
)
return FakeResponse(content)
config = SimpleNamespace(validate=lambda: True)
engine = SimpleNamespace(sample_rate=16_000)
sherpa = SimpleNamespace(
OfflineSpeakerSegmentationPyannoteModelConfig=lambda **kwargs: SimpleNamespace(
**kwargs
),
OfflineSpeakerSegmentationModelConfig=lambda **kwargs: SimpleNamespace(
**kwargs
),
SpeakerEmbeddingExtractorConfig=lambda **kwargs: SimpleNamespace(**kwargs),
FastClusteringConfig=lambda **kwargs: SimpleNamespace(**kwargs),
OfflineSpeakerDiarizationConfig=lambda **kwargs: config,
OfflineSpeakerDiarization=lambda actual_config: engine,
)
with (
patch("huggingface_hub.cached_assets_path", return_value=tmp_path),
patch.dict(sys.modules, {"sherpa_onnx": sherpa}),
patch("httpx.stream", side_effect=fake_stream),
):
load_speaker_diarizer(speakers=2, threads=4)
assert (
tmp_path / "pyannote-segmentation-3.0.onnx"
).read_bytes() == segmentation_bytes
assert (
tmp_path / "wespeaker_en_voxceleb_resnet34_LM.onnx"
).read_bytes() == embedding_bytes
assert len(requested_urls) == 2
assert list(tmp_path.glob("*.tmp")) == []
def test_load_speaker_diarizer_keeps_corrupt_cache_when_download_is_invalid(
tmp_path, monkeypatch
):
segmentation = tmp_path / "pyannote-segmentation-3.0.onnx"
segmentation.write_bytes(b"existing corrupt model")
monkeypatch.setattr(
"local_transcriber.speaker_diarizer._SEGMENTATION_SHA256",
hashlib.sha256(b"expected model").hexdigest(),
)
archive_buffer = io.BytesIO()
with tarfile.open(fileobj=archive_buffer, mode="w:bz2") as archive:
payload = b"wrong downloaded model"
member = tarfile.TarInfo("sherpa-onnx-pyannote-segmentation-3-0/model.onnx")
member.size = len(payload)
archive.addfile(member, io.BytesIO(payload))
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def raise_for_status(self):
return None
def iter_bytes(self):
yield archive_buffer.getvalue()
with (
patch("huggingface_hub.cached_assets_path", return_value=tmp_path),
patch("httpx.stream", return_value=FakeResponse()),
pytest.raises(RuntimeError, match="Контрольная сумма"),
):
load_speaker_diarizer(speakers=None)
assert segmentation.read_bytes() == b"existing corrupt model"
assert list(tmp_path.glob("*.tmp")) == []
+38 -6
View File
@@ -11,7 +11,7 @@ from local_transcriber.transcriber import (
load_model, load_model,
transcribe, transcribe,
) )
from local_transcriber.types import WordTimestampsUnavailableError
# === Helpers === # === Helpers ===
@@ -314,7 +314,9 @@ def test_load_model_returns_backend_and_path(mock_get_backend):
backend = _make_backend(model_path="/mock/model/path") backend = _make_backend(model_path="/mock/model/path")
mock_get_backend.return_value = backend mock_get_backend.return_value = backend
model, actual_device, returned_backend, model_path = load_model("tiny", "cpu", "int8") model, actual_device, returned_backend, model_path = load_model(
"tiny", "cpu", "int8"
)
assert returned_backend is backend assert returned_backend is backend
assert model_path == "/mock/model/path" assert model_path == "/mock/model/path"
@@ -389,7 +391,9 @@ def test_ensure_model_available_uses_cache_first(mock_snapshot_download, tmp_pat
@patch("local_transcriber.backends.faster_whisper._validate_model_dir") @patch("local_transcriber.backends.faster_whisper._validate_model_dir")
@patch("local_transcriber.backends.faster_whisper.snapshot_download") @patch("local_transcriber.backends.faster_whisper.snapshot_download")
def test_ensure_model_available_downloads_on_cache_miss(mock_snapshot_download, mock_validate_model_dir): def test_ensure_model_available_downloads_on_cache_miss(
mock_snapshot_download, mock_validate_model_dir
):
from huggingface_hub.errors import LocalEntryNotFoundError from huggingface_hub.errors import LocalEntryNotFoundError
mock_snapshot_download.side_effect = [ mock_snapshot_download.side_effect = [
@@ -433,7 +437,9 @@ def test_ensure_model_available_rejects_unsupported_alias():
@patch("local_transcriber.backends.faster_whisper.snapshot_download") @patch("local_transcriber.backends.faster_whisper.snapshot_download")
def test_ensure_model_available_redownloads_incomplete_cache(mock_snapshot_download, tmp_path): def test_ensure_model_available_redownloads_incomplete_cache(
mock_snapshot_download, tmp_path
):
incomplete = tmp_path / "incomplete" incomplete = tmp_path / "incomplete"
incomplete.mkdir() incomplete.mkdir()
(incomplete / "config.json").write_text("{}") (incomplete / "config.json").write_text("{}")
@@ -489,7 +495,9 @@ def test_load_model_openvino_gpu_fallback_to_cpu(mock_get_backend):
with pytest.warns(UserWarning, match="Переключение на CPU"): with pytest.warns(UserWarning, match="Переключение на CPU"):
model, actual_device, backend, model_path = load_model( model, actual_device, backend, model_path = load_model(
"medium", "openvino-gpu", "fp16", "medium",
"openvino-gpu",
"fp16",
) )
assert actual_device == "cpu" assert actual_device == "cpu"
@@ -514,7 +522,9 @@ def test_load_model_openvino_cpu_fallback_to_cpu(mock_get_backend):
with pytest.warns(UserWarning, match="Переключение на CPU"): with pytest.warns(UserWarning, match="Переключение на CPU"):
model, actual_device, backend, model_path = load_model( model, actual_device, backend, model_path = load_model(
"medium", "openvino-cpu", "int8", "medium",
"openvino-cpu",
"int8",
) )
assert actual_device == "cpu" assert actual_device == "cpu"
@@ -555,6 +565,28 @@ def test_transcribe_file_openvino_gpu_midstream_fallback(mock_get_backend):
assert tfr.model_path == "/mock/cpu/model" 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") @patch("local_transcriber.transcriber.get_backend")
def test_transcribe_file_midstream_fallback_preserves_cpu_threads(mock_get_backend): def test_transcribe_file_midstream_fallback_preserves_cpu_threads(mock_get_backend):
ov_backend = _make_backend( ov_backend = _make_backend(
Generated
+30
View File
@@ -259,11 +259,13 @@ version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "faster-whisper" }, { name = "faster-whisper" },
{ name = "httpx" },
{ name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "onnx-asr", extra = ["cpu", "hub"] }, { name = "onnx-asr", extra = ["cpu", "hub"] },
{ name = "onnxruntime" }, { name = "onnxruntime" },
{ name = "openvino-genai", marker = "(platform_machine == 'AMD64' and sys_platform != 'darwin') or (platform_machine == 'x86_64' and sys_platform != 'darwin')" }, { name = "openvino-genai", marker = "(platform_machine == 'AMD64' and sys_platform != 'darwin') or (platform_machine == 'x86_64' and sys_platform != 'darwin')" },
{ name = "rich" }, { name = "rich" },
{ name = "sherpa-onnx" },
{ name = "socksio" }, { name = "socksio" },
{ name = "typer" }, { name = "typer" },
] ]
@@ -276,11 +278,13 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "faster-whisper", specifier = ">=1.2.1,<2" }, { 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'", specifier = ">=12.4,<13" },
{ name = "onnx-asr", extras = ["cpu", "hub"], specifier = ">=0.12,<0.13" }, { name = "onnx-asr", extras = ["cpu", "hub"], specifier = ">=0.12,<0.13" },
{ name = "onnxruntime", specifier = ">=1.28,<2" }, { 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 = "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 = "rich", specifier = ">=14.3.3,<15" },
{ name = "sherpa-onnx", specifier = ">=1.13.5,<2" },
{ name = "socksio", specifier = ">=1.0.0,<2" }, { name = "socksio", specifier = ">=1.0.0,<2" },
{ name = "typer", specifier = ">=0.24.1,<1" }, { name = "typer", specifier = ">=0.24.1,<1" },
] ]
@@ -593,6 +597,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
] ]
[[package]]
name = "sherpa-onnx"
version = "1.13.5"
source = { registry = "https://pypi.org/simple" }
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" }
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" },
]
[[package]] [[package]]
name = "socksio" name = "socksio"
version = "1.0.0" version = "1.0.0"