feat(diarization): добавлено разделение транскрипта по говорящим #26
@@ -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 заметного выигрыша не даст.
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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"):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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")) == []
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user