test(diarization): добавлен стенд сравнения CPU-моделей
- Зачем: - требовалось сравнить облегченные модели диаризации на старом CPU без изменения рабочей конфигурации. - Что: - добавлен воспроизводимый и возобновляемый прогон матрицы Pyannote FP32/INT8 с WeSpeaker и TitaNet. - зафиксированы измерения 108 ячеек, ограничения эксперимента и решение сохранить модель по умолчанию. - локальные записи в каталоге tmp исключены из индекса Git. - Проверка: - .venv/bin/python -m pytest. - /tmp/local-transcriber-ruff-bin/ruff check scripts/benchmarks/diarization_calibration.py tests/test_diarization_calibration.py.
This commit is contained in:
1 parent
95f295c37c
commit
1e14f2687a
8 files changed
+2703
-233
No files matched your search
@@ -6,3 +6,4 @@ dist/
|
|||||||
*.pyc
|
*.pyc
|
||||||
.codex
|
.codex
|
||||||
.qwen/
|
.qwen/
|
||||||
|
tmp
|
||||||
@@ -2,6 +2,23 @@
|
|||||||
|
|
||||||
**Статус**: Принято
|
**Статус**: Принято
|
||||||
**Дата**: 2026-08-14
|
**Дата**: 2026-08-14
|
||||||
|
**Обновлено**: 2026-08-30
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Диаризацию реализуем как явно включаемый пост-процессинг через `sherpa-onnx`.
|
||||||
|
Говорящий назначается слову с временной привязкой, а не целому сегменту
|
||||||
|
распознавания. Модель по умолчанию остаётся прежней: Pyannote Segmentation 3.0 FP32,
|
||||||
|
WeSpeaker ResNet34 LM, порог кластеризации `0,89` и автоматическое число
|
||||||
|
кластеров. Известное число говорящих можно передать явно.
|
||||||
|
|
||||||
|
Повторный CPU-тест FP32/INT8 × WeSpeaker/TitaNet не меняет это решение.
|
||||||
|
INT8 с WeSpeaker дал только около 2% среднего ускорения без существенного
|
||||||
|
выигрыша памяти и с худшими прокси-метриками. TitaNet оказался примерно на
|
||||||
|
четверть быстрее, но потребовал ещё 147–149 МиБ RSS и не прошёл условие приёмки
|
||||||
|
для общего автоматического порога. TitaNet можно исследовать только для режима
|
||||||
|
с явно известным числом говорящих. Такой эксперимент должен завершиться
|
||||||
|
слуховой проверкой. Код и модели по умолчанию на него не переключаем.
|
||||||
|
|
||||||
## Контекст
|
## Контекст
|
||||||
|
|
||||||
@@ -21,12 +38,20 @@
|
|||||||
## Эксперимент
|
## Эксперимент
|
||||||
|
|
||||||
Локальная связка `sherpa-onnx` с сегментацией Pyannote 3.0 и эмбеддингами
|
Локальная связка `sherpa-onnx` с сегментацией Pyannote 3.0 и эмбеддингами
|
||||||
WeSpeaker ResNet34 LM проверена на трёх записях с известным составом. Для
|
WeSpeaker ResNet34 LM проверена на трёх записях с известным составом. В исходной
|
||||||
автоматического определения числа голосовых кластеров выбран порог 0,89: это
|
калибровке для автоматического определения числа голосовых кластеров выбран
|
||||||
единственное проверенное значение, которое на трёх контрольных фрагментах дало
|
порог 0,89: среди проверенных тогда значений только он дал на трёх контрольных
|
||||||
3 / 2 / 2 кластера. На полной встрече втроём остался ложный кластер длительностью
|
фрагментах `3 / 2 / 2` кластера. На полной встрече втроём остался ложный кластер
|
||||||
19,1 секунды; поэтому малые кластеры нельзя молча отбрасывать, а разметку нельзя
|
длительностью 19,1 секунды; поэтому малые кластеры нельзя молча отбрасывать, а
|
||||||
считать эталоном точных границ и перекрывающейся речи.
|
разметку нельзя считать эталоном точных границ и перекрывающейся речи.
|
||||||
|
|
||||||
|
В повторном CPU-переборе на тех же пятиминутных фрагментах FP32 + WeSpeaker
|
||||||
|
прошёл условие приёмки `3 / 2 / 2` на плато `0,85–0,89`, а INT8 + WeSpeaker —
|
||||||
|
на `0,85–0,90`.
|
||||||
|
Это не отменяет порог 0,89: новый прогон не имел повторов и новой слуховой
|
||||||
|
проверки. Обе связки TitaNet провалили условие приёмки во всех проверенных
|
||||||
|
точках; число содержательных кластеров не использовалось как оправдание лишних
|
||||||
|
голосовых кластеров.
|
||||||
|
|
||||||
Двуязычная CAMPPlus zh/en оказалась примерно на 30% быстрее и при известном
|
Двуязычная CAMPPlus zh/en оказалась примерно на 30% быстрее и при известном
|
||||||
числе участников улучшила прокси-метрику на двух записях, но для неё не нашлось
|
числе участников улучшила прокси-метрику на двух записях, но для неё не нашлось
|
||||||
@@ -44,6 +69,8 @@ WeSpeaker ResNet34 LM проверена на трёх записях с изв
|
|||||||
[калибровке](../benchmarks/2026-08-14-diarization-calibration.md),
|
[калибровке](../benchmarks/2026-08-14-diarization-calibration.md),
|
||||||
[смешении говорящих](../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).
|
||||||
|
Сравнение облегчённых моделей и границы его применимости описаны в
|
||||||
|
[отдельном отчёте](../benchmarks/2026-08-30-lightweight-diarization-cpu.md).
|
||||||
|
|
||||||
### Сглаживание неизвестного говорящего
|
### Сглаживание неизвестного говорящего
|
||||||
|
|
||||||
@@ -78,12 +105,7 @@ speaker Markdown хранит только начало реплики. Тем
|
|||||||
невозможна, потому что в нём уже потеряны доли секунды, конец слова и причина
|
невозможна, потому что в нём уже потеряны доли секунды, конец слова и причина
|
||||||
неопределённости.
|
неопределённости.
|
||||||
|
|
||||||
## Решение
|
## Сведение слов и разметки говорящих
|
||||||
|
|
||||||
Диаризацию реализуем как явно включаемый пост-процессинг через `sherpa-onnx`.
|
|
||||||
Первая версия использует Pyannote segmentation 3.0, WeSpeaker ResNet34 LM,
|
|
||||||
порог кластеризации 0,89 и автоматическое число кластеров; известное число
|
|
||||||
участников можно передать явно.
|
|
||||||
|
|
||||||
Говорящий назначается [слову с временной
|
Говорящий назначается [слову с временной
|
||||||
привязкой](../../CONTEXT.md#language), а не сегменту распознавания. Каждый
|
привязкой](../../CONTEXT.md#language), а не сегменту распознавания. Каждый
|
||||||
@@ -119,6 +141,9 @@ ASR-бэкенд приводит свой результат к общему н
|
|||||||
- Последовательный режим задаёт корректный baseline. Параллельный запуск и
|
- Последовательный режим задаёт корректный baseline. Параллельный запуск и
|
||||||
автоматическое включение на мощных устройствах требуют отдельных измерений
|
автоматическое включение на мощных устройствах требуют отдельных измерений
|
||||||
после стабилизации.
|
после стабилизации.
|
||||||
|
- Pyannote INT8 и TitaNet не становятся конфигурациями по умолчанию только на
|
||||||
|
основании CPU-теста. Для TitaNet следующий допустимый эксперимент —
|
||||||
|
режим с явно известным числом говорящих, повторами и слуховой проверкой.
|
||||||
|
|
||||||
## Отклонённые альтернативы
|
## Отклонённые альтернативы
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
# Облегчённые модели диаризации на старом Intel Core i7
|
||||||
|
|
||||||
|
**Дата:** 2026-08-30
|
||||||
|
|
||||||
|
**Статус:** сравнительный CPU-тест. Конфигурация по умолчанию не менялась.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Сохранить модель по умолчанию: Pyannote Segmentation 3.0 FP32, WeSpeaker
|
||||||
|
ResNet34 LM, общий порог автоматической кластеризации `0,89`. Новый прогон
|
||||||
|
подтвердил для этой связки точное число голосовых кластеров `3 / 2 / 2` на всех
|
||||||
|
трёх контрольных фрагментах.
|
||||||
|
|
||||||
|
Pyannote INT8 с WeSpeaker не заменяет FP32. Среднее ускорение составило только
|
||||||
|
2,1%, средний пиковый RSS по ячейкам снизился примерно на 4 МиБ, а средняя
|
||||||
|
чистота сопоставленных говорящих и число слов без говорящего стали хуже.
|
||||||
|
|
||||||
|
TitaNet small в среднем быстрее WeSpeaker на 24,3%, но требует примерно на
|
||||||
|
147–149 МиБ больше RSS и не находит правильное число кластеров ни в одной
|
||||||
|
проверенной точке общего автоматического порога. Его можно рассматривать только
|
||||||
|
как кандидата для режима с явно известным числом говорящих. Следующий эксперимент
|
||||||
|
должен завершиться отдельной слуховой проверкой. По этому тесту менять код,
|
||||||
|
модели или пороги нельзя.
|
||||||
|
|
||||||
|
## Границы вывода
|
||||||
|
|
||||||
|
Проверялись только четыре CPU-сочетания Pyannote FP32/INT8 ×
|
||||||
|
WeSpeaker/TitaNet на трёх пятиминутных русскоязычных рабочих созвонах из
|
||||||
|
[исходной калибровки](2026-08-14-diarization-calibration.md). Число говорящих в
|
||||||
|
них известно как `3 / 2 / 2`. Это сравнительный прогон моделей диаризации, а не
|
||||||
|
новая приёмка готовых транскриптов.
|
||||||
|
|
||||||
|
В частности:
|
||||||
|
|
||||||
|
- новой слуховой проверки не было;
|
||||||
|
- полные записи не прогонялись;
|
||||||
|
- у каждой ячейки один запуск, без повторов и случайного перемешивания порядка;
|
||||||
|
- влияние порядка, прогрева и температуры отдельно не измерено;
|
||||||
|
- ASR выполнен один раз на запись и не входит во время ячеек диаризации;
|
||||||
|
- параллельные ASR и диаризация, GPU, NPU и другие CPU не проверялись.
|
||||||
|
|
||||||
|
Происхождение, лицензии и закреплённые SHA-256 моделей описаны в
|
||||||
|
[исследовательской записке](../research/2026-08-29-lightweight-diarization-models.md),
|
||||||
|
а смысл `mapped speaker purity` и ограничения опорной разметки — в
|
||||||
|
[исходной калибровке](2026-08-14-diarization-calibration.md). Здесь эти сведения
|
||||||
|
не дублируются.
|
||||||
|
|
||||||
|
## Методика
|
||||||
|
|
||||||
|
Каждая измеряемая ячейка запускалась в новом дочернем процессе. До измеряемого
|
||||||
|
интервала загружались WAV, модели и сэмплы; полное время, CPU и пиковый RSS
|
||||||
|
считались вокруг одного вызова `diarizer.process(samples)`. Отладочный журнал
|
||||||
|
`sherpa-onnx` 1.13.5 дал время сегментации, извлечения эмбеддингов, кластеризации
|
||||||
|
и всего вызова.
|
||||||
|
Декодирование, загрузка моделей, сериализация и ASR во время не входят.
|
||||||
|
|
||||||
|
Автоматический перебор прямоугольный: каждый порог сочетания проверен на всех
|
||||||
|
трёх записях с `num_clusters=-1`. Для сочетания выбирался один общий порог.
|
||||||
|
Условие приёмки считалось выполненным только при полном совпадении общего числа
|
||||||
|
кластеров с
|
||||||
|
`3 / 2 / 2`; число содержательных кластеров оставалось диагностикой и не могло
|
||||||
|
оправдать лишний кластер. После выбора порога каждое сочетание ещё раз прошло
|
||||||
|
три записи с известным числом кластеров.
|
||||||
|
|
||||||
|
Итого сохранено 108 успешных ячеек без ошибок:
|
||||||
|
|
||||||
|
- 96 с автоматическим числом кластеров: по 18 для каждого сочетания с
|
||||||
|
WeSpeaker и по 30 для каждого сочетания с TitaNet;
|
||||||
|
- 12 с известным числом кластеров: по три для каждого из четырёх сочетаний.
|
||||||
|
|
||||||
|
Отдельно сохранены четыре результата выбора общего порога. Они описывают
|
||||||
|
агрегацию автоматических ячеек и в число 108 не входят.
|
||||||
|
|
||||||
|
Во всех 108 ячейках совпала последовательность 1853 распознанных слов после
|
||||||
|
удаления Unicode-пробелов. Инвариантность текста не нарушена.
|
||||||
|
|
||||||
|
## Оборудование и условия
|
||||||
|
|
||||||
|
- Intel Core i7-6820HQ, 4 ядра / 8 логических процессоров;
|
||||||
|
- Linux 7.0.0-30-generic x86_64;
|
||||||
|
- питание от сети, системный профиль `balanced`;
|
||||||
|
- Python 3.13.13, `sherpa-onnx` 1.13.5, `onnxruntime` 1.28.0,
|
||||||
|
NumPy 2.4.3;
|
||||||
|
- CPU provider и 8 потоков для segmentation и embedding;
|
||||||
|
- mono PCM16, 16 кГц, длительность каждого фрагмента 300 секунд.
|
||||||
|
|
||||||
|
Это тот же ноутбук, что в [предыдущей Intel-приёмке](2026-08-14-diarization-intel-i7.md),
|
||||||
|
но условия нельзя считать побайтовым повтором: там отдельно зафиксированы
|
||||||
|
governor `powersave` и урезанная доступная мощность, а версия ядра была
|
||||||
|
7.0.0-29. Во время нового перебора ночная пауза произошла до сохранения текущей
|
||||||
|
ячейки. Продолжение началось после возврата профиля `balanced`; ячеек,
|
||||||
|
измеренных в энергосберегающем профиле, в итоговом результате нет.
|
||||||
|
|
||||||
|
## Производительность полного автоматического перебора
|
||||||
|
|
||||||
|
Числа ниже — средние по всем автоматическим ячейкам сочетания. Это не повторы
|
||||||
|
одной ячейки. «Средний пиковый RSS» — среднее пикового RSS каждой ячейки;
|
||||||
|
соседний столбец показывает наибольший такой максимум во всём переборе.
|
||||||
|
|
||||||
|
| Сегментация + эмбеддинги | Ячеек | Время, с | RTF | CPU, ядер | Средний пиковый RSS, МиБ | Максимальный пиковый RSS, МиБ | Сегментация, с | Эмбеддинги, с |
|
||||||
|
|---|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||||
|
| FP32 + WeSpeaker | 18 | 72,486 | 0,24162 | 6,84 | 254,55 | 265,61 | 15,295 | 56,837 |
|
||||||
|
| INT8 + WeSpeaker | 18 | 70,969 | 0,23656 | 6,94 | 250,40 | 263,00 | 17,094 | 53,524 |
|
||||||
|
| FP32 + TitaNet | 30 | 52,797 | 0,17599 | 6,80 | 401,80 | 428,11 | 14,724 | 37,723 |
|
||||||
|
| INT8 + TitaNet | 30 | 55,792 | 0,18597 | 6,70 | 399,10 | 419,71 | 17,125 | 38,278 |
|
||||||
|
|
||||||
|
INT8 ускорил сочетание с WeSpeaker на 2,1%, но сегментация стала медленнее, а
|
||||||
|
выигрыш возник на извлечении эмбеддингов. Такой результат не даёт практического
|
||||||
|
основания заменять FP32. У TitaNet FP32 оказался быстрее INT8. Относительно
|
||||||
|
соответствующей связки WeSpeaker ускорение TitaNet составило 27,2% для FP32 и
|
||||||
|
21,4% для INT8.
|
||||||
|
Среднее для двух вариантов точности — 24,3%.
|
||||||
|
|
||||||
|
Опубликованное ожидание ускорения от INT8 локально не подтвердилось. Вместо
|
||||||
|
18,9% с 3D-Speaker получено 2,1% с WeSpeaker, а с TitaNet INT8 оказался на 5,7%
|
||||||
|
медленнее FP32. Условия исходного примера и границы такого сравнения разобраны
|
||||||
|
в [исследовательской записке](../research/2026-08-29-lightweight-diarization-models.md).
|
||||||
|
|
||||||
|
За скорость TitaNet платит памятью: средний пиковый RSS выше WeSpeaker на 147,25
|
||||||
|
МиБ с FP32 и на 148,70 МиБ с INT8. Поэтому на слабом CPU его нельзя считать
|
||||||
|
безусловно облегчённым вариантом.
|
||||||
|
|
||||||
|
## Автоматический порог и диагностика качества
|
||||||
|
|
||||||
|
`Mapped speaker purity` усреднена только по T2 BDMA и Yantar, для которых есть
|
||||||
|
грубая опорная разметка. Это сравнительная прокси-метрика, не DER и не точность
|
||||||
|
по выверенной разметке.
|
||||||
|
|
||||||
|
| Сочетание | Выбранный порог | Приёмка `3 / 2 / 2` | Кластеры | Средняя purity | Слов без говорящего, из 1853 | Максимальный остаток сверх ожидаемых |
|
||||||
|
|---|---:|:---:|---|---:|---:|---:|
|
||||||
|
| FP32 + WeSpeaker | 0,85 | да | 3 / 2 / 2 | 0,82950 | 71 | 0% |
|
||||||
|
| INT8 + WeSpeaker | 0,85 | да | 3 / 2 / 2 | 0,81028 | 104 | 0% |
|
||||||
|
| FP32 + TitaNet | 1,10 | **нет** | 5 / 3 / 3 | 0,82287 | 76 | 3,89% |
|
||||||
|
| INT8 + TitaNet | 1,10 | **нет** | 3 / 2 / 3 | 0,79315 | 109 | 12,12% |
|
||||||
|
|
||||||
|
У FP32 + WeSpeaker условие приёмки проходит на плато `0,85–0,89`, у INT8 +
|
||||||
|
WeSpeaker — на `0,85–0,90`. Автоматический выбор дал `0,85` по общей
|
||||||
|
лексикографической оценке, но это не аргумент менять рабочий порог `0,89`:
|
||||||
|
новый перебор не содержит повторов или слуховой проверки, тогда как действующее
|
||||||
|
решение опирается также на предыдущую калибровку полных записей и слуховую
|
||||||
|
проверку плотного диалога.
|
||||||
|
|
||||||
|
TitaNet не прошёл условие приёмки ни в одной точке проверенной сетки `0,50–1,10`.
|
||||||
|
Порог `1,10` — лишь наименее плохая точка перебора; допустимой автоматической
|
||||||
|
конфигурацией она не является. Содержательные `3 / 2 / 3` кластера у FP32 TitaNet не отменяют
|
||||||
|
лишние голосовые кластеры: диагностический фильтр не заменяет условие приёмки.
|
||||||
|
|
||||||
|
«Максимальный остаток сверх ожидаемых» — наибольшая по трём записям доля слов,
|
||||||
|
которые попали в кластеры сверх известного числа говорящих. Метрика описывает
|
||||||
|
объём лишних кластеров, а не их количество: один крупный лишний кластер может
|
||||||
|
дать больший процент, чем несколько малых.
|
||||||
|
|
||||||
|
## Режим с известным числом говорящих
|
||||||
|
|
||||||
|
Ячейки с известным числом говорящих использовали тот же выбранный порог и
|
||||||
|
меняли только `num_clusters` на известные `3 / 2 / 2`. Все четыре сочетания
|
||||||
|
вернули точное число кластеров на всех трёх записях.
|
||||||
|
|
||||||
|
| Сочетание | Точное число кластеров | Точное число содержательных кластеров | Средняя purity |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| FP32 + WeSpeaker | 3 / 3 записей | 3 / 3 записей | 0,82950 |
|
||||||
|
| INT8 + WeSpeaker | 3 / 3 записей | 3 / 3 записей | 0,81028 |
|
||||||
|
| FP32 + TitaNet | 3 / 3 записей | 2 / 3 записей | 0,83057 |
|
||||||
|
| INT8 + TitaNet | 3 / 3 записей | 3 / 3 записей | 0,79721 |
|
||||||
|
|
||||||
|
Близкая средняя purity FP32 TitaNet и FP32 WeSpeaker не доказывает равноценное
|
||||||
|
качество. У FP32 TitaNet на Data Test один из трёх принудительных кластеров не
|
||||||
|
достиг диагностического порога содержательности, а новой слуховой проверки не
|
||||||
|
было. Поэтому TitaNet остаётся только направлением будущего эксперимента с
|
||||||
|
обязательным известным числом говорящих.
|
||||||
|
|
||||||
|
## Ограничения и следующий шаг
|
||||||
|
|
||||||
|
- Три записи относятся к одному типу рабочих созвонов; результат не переносится
|
||||||
|
автоматически на другие языки, микрофоны и акустические условия.
|
||||||
|
- Две опорные разметки имеют секундную точность и не позволяют считать DER.
|
||||||
|
- Средние значения полного времени и RSS объединяют разные записи и пороги.
|
||||||
|
Без повторов они не оценивают дисперсию и не отделяют порядок, прогрев или
|
||||||
|
влияние температуры.
|
||||||
|
- Новый прогон не заменяет прежнюю слуховую проверку и не даёт слуховых данных
|
||||||
|
для TitaNet или INT8.
|
||||||
|
- Результат относится к последовательной CPU-диаризации на одном Core
|
||||||
|
i7-6820HQ и не предсказывает конкретный Core i5 11-го поколения.
|
||||||
|
|
||||||
|
Если возвращаться к TitaNet, следующий эксперимент должен заранее требовать
|
||||||
|
известное число говорящих, включать повторные прогоны с контролем порядка и
|
||||||
|
температуры и завершаться слуховой проверкой всех трёх записей. До этого
|
||||||
|
конфигурацию и модель по умолчанию не менять.
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
# Облегчённые модели диаризации: Pyannote INT8 и TitaNet small
|
||||||
|
|
||||||
|
**Дата:** 2026-08-29
|
||||||
|
|
||||||
|
**Статус:** исследовательская записка. Код, зависимости и рабочая конфигурация
|
||||||
|
не менялись.
|
||||||
|
|
||||||
|
**Метод:** использованы только первичные публичные источники: документация,
|
||||||
|
исходный код и выпуски sherpa-onnx, карточки владельцев моделей и исходная
|
||||||
|
публикация TitaNet. Приватные медиа и опорные транскрипты Hypescribe не
|
||||||
|
открывались.
|
||||||
|
|
||||||
|
## Краткий вывод
|
||||||
|
|
||||||
|
Sherpa-onnx официально документирует все четыре сочетания Pyannote
|
||||||
|
Segmentation 3.0 FP32/INT8 с 3D-Speaker и `nemo_en_titanet_small.onnx`. В
|
||||||
|
опубликованном разработчиками примере производительности переход FP32 → INT8
|
||||||
|
сокращает
|
||||||
|
RTF с 0,297 до 0,241 с 3D-Speaker и с 0,119 до 0,110 с TitaNet small. Но это
|
||||||
|
одна 56,861-секундная китайская контрольная запись, известные четыре говорящих,
|
||||||
|
CPU-провайдер и по одному потоку на сегментацию и эмбеддинги. Данных о
|
||||||
|
конкретном CPU, повторностях, разбросе и качестве на русском материале страница
|
||||||
|
не приводит. Поэтому цифры подтверждают совместимость и потенциал, но не
|
||||||
|
заменяют локальный тест. Условия и результаты взяты с [официальной
|
||||||
|
страницы sherpa-onnx](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/models.html).
|
||||||
|
|
||||||
|
Официальные файлы выпусков доступны под следующими именами:
|
||||||
|
|
||||||
|
- `sherpa-onnx-pyannote-segmentation-3-0.tar.bz2` содержит
|
||||||
|
`model.onnx` (FP32) и `model.int8.onnx`;
|
||||||
|
- `nemo_en_titanet_small.onnx` поставляется отдельным ONNX-файлом.
|
||||||
|
|
||||||
|
GitHub API не публикует `digest` ни для одного из этих файлов. Ниже зафиксированы
|
||||||
|
SHA-256 байтов, скачанных 2026-08-29 с официальных URL, и точные размеры,
|
||||||
|
совпавшие с GitHub API. Эти хеши пригодны для закрепления текущей поставки, но
|
||||||
|
получены в этой проверке и не заявлены разработчиками как контрольные суммы.
|
||||||
|
|
||||||
|
## Официальная поддержка и точные артефакты
|
||||||
|
|
||||||
|
### Pyannote Segmentation 3.0: FP32 и INT8 в одном архиве
|
||||||
|
|
||||||
|
Официальная документация sherpa-onnx называет модель
|
||||||
|
`sherpa-onnx-pyannote-segmentation-3-0`, указывает, что она конвертирована из
|
||||||
|
[`pyannote/segmentation-3.0`](https://huggingface.co/pyannote/segmentation-3.0),
|
||||||
|
ссылается на [официальный каталог скриптов
|
||||||
|
конвертации](https://github.com/k2-fsa/sherpa-onnx/tree/master/scripts/pyannote/segmentation)
|
||||||
|
и показывает оба файла, `model.onnx` и `model.int8.onnx`, в одном архиве
|
||||||
|
([документация и команда загрузки](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/models.html#download-the-model)).
|
||||||
|
|
||||||
|
Точное имя и URL:
|
||||||
|
|
||||||
|
```text
|
||||||
|
sherpa-onnx-pyannote-segmentation-3-0.tar.bz2
|
||||||
|
https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-segmentation-models/sherpa-onnx-pyannote-segmentation-3-0.tar.bz2
|
||||||
|
```
|
||||||
|
|
||||||
|
На момент проверки [GitHub Releases
|
||||||
|
API](https://api.github.com/repos/k2-fsa/sherpa-onnx/releases/tags/speaker-segmentation-models)
|
||||||
|
сообщает размер файла `6 958 444` байта,
|
||||||
|
`created_at=2024-10-08T12:54:09Z`, `updated_at=2024-10-08T12:54:10Z` и
|
||||||
|
`digest=null`; сам [официальный выпуск](https://github.com/k2-fsa/sherpa-onnx/releases/tag/speaker-segmentation-models)
|
||||||
|
содержит этот файл, а документация даёт тот же URL загрузки.
|
||||||
|
|
||||||
|
Проверенное содержимое архива:
|
||||||
|
|
||||||
|
| Файл | Размер | SHA-256 |
|
||||||
|
|---|---:|---|
|
||||||
|
| `model.onnx` | 5 992 913 байт | `220ad67ca923bef2fa91f2390c786097bf305bceb5e261d4af67b38e938e1079` |
|
||||||
|
| `model.int8.onnx` | 1 540 506 байт | `d582f4b4c6b48205de7e0643c57df0df5615a3c176189be3fc461e9d18827b5d` |
|
||||||
|
|
||||||
|
Архив также содержит `LICENSE`, `README.md` и скрипты экспорта/проверки. Это
|
||||||
|
согласуется с опубликованным sherpa-onnx листингом: примерно 5,7 МБ для FP32 и
|
||||||
|
1,5 МБ для INT8
|
||||||
|
([официальный листинг](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/models.html#download-the-model)).
|
||||||
|
|
||||||
|
### NeMo TitaNet small: отдельная модель эмбеддингов
|
||||||
|
|
||||||
|
Официальная страница примеров загружает `nemo_en_titanet_small.onnx` из выпуска
|
||||||
|
моделей распознавания говорящих и использует его как `--embedding.model` вместе с
|
||||||
|
обоими вариантами Pyannote
|
||||||
|
([FP32](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/models.html#nemo-model-onnx),
|
||||||
|
[INT8](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/models.html#nemo-model-int8-onnx)).
|
||||||
|
Отдельная [страница моделей NeMo в sherpa-onnx](https://k2-fsa.github.io/sherpa/onnx/nemo/index.html)
|
||||||
|
перечисляет это точное имя среди поддерживаемых моделей эмбеддингов говорящих.
|
||||||
|
|
||||||
|
Точное имя и URL:
|
||||||
|
|
||||||
|
```text
|
||||||
|
nemo_en_titanet_small.onnx
|
||||||
|
https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/nemo_en_titanet_small.onnx
|
||||||
|
```
|
||||||
|
|
||||||
|
Опечатка `recongition` является частью официальной метки выпуска и URL; исправлять
|
||||||
|
её на `recognition` нельзя. На момент проверки [GitHub Releases
|
||||||
|
API](https://api.github.com/repos/k2-fsa/sherpa-onnx/releases/tags/speaker-recongition-models)
|
||||||
|
сообщает размер файла
|
||||||
|
`40 257 283` байта, `created_at=2024-10-14T07:03:02Z`,
|
||||||
|
`updated_at=2024-10-14T07:03:03Z` и `digest=null`; [выпуск
|
||||||
|
sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx/releases/tag/speaker-recongition-models)
|
||||||
|
предупреждает, что лицензия у каждой модели своя и её нужно искать в исходном
|
||||||
|
репозитории модели.
|
||||||
|
|
||||||
|
## Контекст опубликованных результатов
|
||||||
|
|
||||||
|
### Пример производительности sherpa-onnx
|
||||||
|
|
||||||
|
Все четыре числа ниже опубликованы в одном примере sherpa-onnx. В нём используется
|
||||||
|
`0-four-speakers-zh.wav` длительностью 56,861 с, явно задано
|
||||||
|
`--clustering.num-clusters=4`, а распечатанная конфигурация показывает CPU
|
||||||
|
провайдер и `num_threads=1` отдельно для сегментации и эмбеддингов. Страница не
|
||||||
|
указывает модель CPU, не описывает прогрев, число повторов или дисперсию и не
|
||||||
|
публикует метрику качества против эталонной разметки
|
||||||
|
([полный пример](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/models.html#sherpa-onnx-pyannote-segmentation-3-0)).
|
||||||
|
|
||||||
|
| Segmentation | Embedding | Время | RTF |
|
||||||
|
|---|---|---:|---:|
|
||||||
|
| Pyannote FP32 `model.onnx` | 3D-Speaker ERes2Net base | 16,870 с | 0,297 |
|
||||||
|
| Pyannote INT8 `model.int8.onnx` | 3D-Speaker ERes2Net base | 13,679 с | 0,241 |
|
||||||
|
| Pyannote FP32 `model.onnx` | NeMo TitaNet small | 6,756 с | 0,119 |
|
||||||
|
| Pyannote INT8 `model.int8.onnx` | NeMo TitaNet small | 6,231 с | 0,110 |
|
||||||
|
|
||||||
|
По опубликованным RTF мы пересчитали относительное ускорение. INT8 ускоряет
|
||||||
|
сочетание с 3D-Speaker примерно на 18,9%, а сочетание с TitaNet small —
|
||||||
|
примерно на 7,6%. Замена 3D-Speaker на TitaNet small при сохранении FP32 даёт
|
||||||
|
около 59,9%, а сочетание TitaNet small + INT8 относительно исходного FP32 +
|
||||||
|
3D-Speaker — около 63,0%. Здесь сравнивается только время выполнения на
|
||||||
|
указанной записи. По этим цифрам нельзя судить о DER, переносимости ускорения
|
||||||
|
на другой CPU или качестве на русском языке
|
||||||
|
([исходные времена и RTF](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/models.html#sherpa-onnx-pyannote-segmentation-3-0)).
|
||||||
|
|
||||||
|
### Что означает «small» в публикации TitaNet
|
||||||
|
|
||||||
|
Исходная статья определяет TitaNet-S как вариант с 256 каналами и 6,4 млн
|
||||||
|
параметров. Для очищенного набора VoxCeleb1 в задаче верификации говорящих она
|
||||||
|
сообщает EER 1,15% при сравнении по косинусному сходству. Для диаризации авторы
|
||||||
|
используют эталонный детектор голосовой активности (oracle SAD), кластеризацию
|
||||||
|
NME-SC, допуск 0,25 с и исключают перекрытия из подсчёта DER. При
|
||||||
|
известном числе говорящих DER TitaNet-S равен 6,37 / 2,00 / 2,22 / 1,11% на
|
||||||
|
NIST-SRE-2000 / AMI-Lapel / AMI-MixHeadset / CH109; при оценённом числе —
|
||||||
|
5,49 / 2,30 / 1,97 / 1,42%. Это тест архитектуры и исходной контрольной точки
|
||||||
|
NeMo в другом конвейере диаризации, не тест ONNX-экспорта внутри
|
||||||
|
sherpa-onnx
|
||||||
|
([первичная публикация TitaNet](https://arxiv.org/abs/2110.04410)).
|
||||||
|
|
||||||
|
Карточка NVIDIA NGC подтверждает назначение `titanet_small` для верификации
|
||||||
|
говорящих, диаризации и извлечения 192-мерных эмбеддингов, но её
|
||||||
|
раздел Performance содержит внутренне противоречивую фразу «TitaNet-L» рядом с
|
||||||
|
6,4 млн параметров. Поэтому для чисел TitaNet-S выше использована исходная
|
||||||
|
статья вместо этой фразы карточки
|
||||||
|
([официальная карточка NGC](https://catalog.ngc.nvidia.com/orgs/nvidia/teams/nemo/models/titanet_small)).
|
||||||
|
|
||||||
|
## Происхождение моделей и лицензии
|
||||||
|
|
||||||
|
### Pyannote Segmentation 3.0
|
||||||
|
|
||||||
|
Исходная карточка Pyannote описывает 10-секундный mono-вход 16 кГц и powerset
|
||||||
|
выход из семи классов: тишина, три одиночных говорящих и три пары перекрывающихся
|
||||||
|
говорящих. Модель обучил Séverin Baroudi в `pyannote.audio` 3.0.0 на сочетании
|
||||||
|
AISHELL, AliMeeting, AMI, AVA-AVD, DIHARD, Ego4D, MSDWild, REPERE и VoxConverse
|
||||||
|
([карточка исходной модели](https://huggingface.co/pyannote/segmentation-3.0)).
|
||||||
|
|
||||||
|
Исходный репозиторий помечает модель лицензией MIT и требует принять условия
|
||||||
|
доступа к его файлам на Hugging Face. Публичный архив sherpa-onnx включает
|
||||||
|
собственный `LICENSE` с текстом MIT; в проверенном архиве copyright указан как
|
||||||
|
`Copyright (c) 2022 CNRS`, тогда как текущий LICENSE исходного репозитория
|
||||||
|
указывает 2023. Тип лицензии совпадает, но при дальнейшей поставке следует
|
||||||
|
сохранять именно `LICENSE` из закреплённого архива
|
||||||
|
([лицензия и условия исходной модели](https://huggingface.co/pyannote/segmentation-3.0),
|
||||||
|
[текущий LICENSE upstream](https://huggingface.co/pyannote/segmentation-3.0/blob/main/LICENSE)).
|
||||||
|
|
||||||
|
### NeMo TitaNet small
|
||||||
|
|
||||||
|
Sherpa-onnx публикует [скрипт экспорта](https://github.com/k2-fsa/sherpa-onnx/blob/master/scripts/nemo/speaker-verification/export-onnx.py),
|
||||||
|
который загружает `EncDecSpeakerLabelModel.from_pretrained(model_name="titanet_small")`,
|
||||||
|
экспортирует `nemo_en_titanet_small.onnx` и добавляет ONNX metadata с framework
|
||||||
|
`nemo`, language `English`, source URL NGC, sample rate и размерностью embedding.
|
||||||
|
[README каталога экспорта](https://github.com/k2-fsa/sherpa-onnx/tree/master/scripts/nemo/speaker-verification)
|
||||||
|
также прямо называет NGC `titanet_small` источником модели.
|
||||||
|
|
||||||
|
Карточка NGC называет актуальную опубликованную версию `1.19.0`, обновлённую
|
||||||
|
7 июня 2023 года, и описывает обучение на VoxCeleb 1/2, RIR noise, Fisher,
|
||||||
|
Switchboard и LibriSpeech. Реализация NeMo связывает имя `titanet_small` с
|
||||||
|
checkpoint `titanet-s.nemo` версии `1.19.0`
|
||||||
|
([карточка NVIDIA NGC](https://catalog.ngc.nvidia.com/orgs/nvidia/teams/nemo/models/titanet_small),
|
||||||
|
[официальный реестр pretrained-моделей NeMo](https://github.com/NVIDIA-NeMo/Speech/blob/main/nemo/collections/asr/models/label_models.py)).
|
||||||
|
|
||||||
|
NGC говорит, что использование модели регулируется лицензией NeMo; ссылка из
|
||||||
|
карточки ведёт на Apache License 2.0. Сам файл выпуска sherpa-onnx — одиночный
|
||||||
|
ONNX-файл без соседнего LICENSE, а описание выпуска перекладывает проверку
|
||||||
|
лицензии на пользователя. Поэтому при включении модели в приложение нужно
|
||||||
|
зафиксировать происхождение NVIDIA NeMo/TitaNet-S и приложить лицензию Apache-2.0,
|
||||||
|
а не считать общий LICENSE sherpa-onnx лицензией весов
|
||||||
|
([секция Licence карточки NGC](https://catalog.ngc.nvidia.com/orgs/nvidia/teams/nemo/models/titanet_small),
|
||||||
|
[LICENSE NeMo](https://github.com/NVIDIA-NeMo/Speech/blob/main/LICENSE),
|
||||||
|
[предупреждение release sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx/releases/tag/speaker-recongition-models)).
|
||||||
|
|
||||||
|
Происхождение нельзя воспроизвести полностью: текущий `run.sh` sherpa-onnx
|
||||||
|
устанавливает NeMo из ветки `main` без закрепления коммита или метки, затем
|
||||||
|
вызывает экспорт
|
||||||
|
для `titanet_small`. Он документирует процесс, но сам по себе не доказывает,
|
||||||
|
из какого коммита NeMo был создан файл от 2024-10-14. Для побайтовой
|
||||||
|
воспроизводимости следует считать официально скачанный ONNX плюс SHA-256 ниже
|
||||||
|
канонической единицей, пока разработчики не опубликуют данные закреплённой сборки
|
||||||
|
([официальный `run.sh`](https://github.com/k2-fsa/sherpa-onnx/blob/master/scripts/nemo/speaker-verification/run.sh)).
|
||||||
|
|
||||||
|
## Проверенные SHA-256
|
||||||
|
|
||||||
|
Файлы скачаны 2026-08-29 напрямую с двух URL выше в `/tmp`; размер каждого
|
||||||
|
сверен с полем `size` GitHub Releases API. Архив дополнительно проверен командой
|
||||||
|
`bzip2 -t`. Поле `digest` в API было `null` для обоих файлов, поэтому ни один из
|
||||||
|
следующих хешей нельзя называть опубликованной контрольной суммой.
|
||||||
|
|
||||||
|
```text
|
||||||
|
24615ee884c897d9d2ba09bb4d30da6bb1b15e685065962db5b02e76e4996488 sherpa-onnx-pyannote-segmentation-3-0.tar.bz2
|
||||||
|
ad4a1802485d8b34c722d2a9d04249662f2ece5d28a7a039063ca22f515a789e nemo_en_titanet_small.onnx
|
||||||
|
```
|
||||||
|
|
||||||
|
Внутри проверенного Pyannote-архива:
|
||||||
|
|
||||||
|
```text
|
||||||
|
220ad67ca923bef2fa91f2390c786097bf305bceb5e261d4af67b38e938e1079 model.onnx
|
||||||
|
d582f4b4c6b48205de7e0643c57df0df5615a3c176189be3fc461e9d18827b5d model.int8.onnx
|
||||||
|
```
|
||||||
|
|
||||||
|
Команда проверки после загрузки:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sha256sum \
|
||||||
|
sherpa-onnx-pyannote-segmentation-3-0.tar.bz2 \
|
||||||
|
nemo_en_titanet_small.onnx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Результат локальной проверки
|
||||||
|
|
||||||
|
[Прогон на Core i7-6820HQ](../benchmarks/2026-08-30-lightweight-diarization-cpu.md)
|
||||||
|
не подтвердил опубликованное ускорение INT8: сочетание с WeSpeaker выиграло
|
||||||
|
только 2,1%, а с TitaNet стало на 5,7% медленнее. TitaNet ускорил обработку в
|
||||||
|
среднем на 24,3%, но потребовал примерно на 147–149 МиБ больше RSS и не прошёл
|
||||||
|
приёмку общего автоматического порога.
|
||||||
|
|
||||||
|
## Практический вердикт
|
||||||
|
|
||||||
|
1. Для минимального эксперимента можно заменить только путь Pyannote
|
||||||
|
`model.onnx` на `model.int8.onnx`: это официально поставляемый вариант того же
|
||||||
|
архива и не требует менять модель эмбеддингов
|
||||||
|
([официальный пример INT8](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/models.html#d-speaker-model-int8-onnx)).
|
||||||
|
2. Для большего потенциального ускорения sherpa-onnx официально принимает
|
||||||
|
`nemo_en_titanet_small.onnx` с обоими вариантами Pyannote, но опубликованный
|
||||||
|
пример производительности не измеряет DER и не относится к русской речи
|
||||||
|
([официальные примеры NeMo](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/models.html#nemo-model-onnx)).
|
||||||
|
3. Для воспроизводимой поставки следует закрепить точные URL, размеры и SHA-256,
|
||||||
|
хранить MIT LICENSE из Pyannote-архива и явно приложить сведения об авторстве
|
||||||
|
и лицензии Apache-2.0 для TitaNet/NeMo. До локальной калибровки нельзя
|
||||||
|
переносить ни коэффициент ускорения, ни порог кластеризации, ни качество с
|
||||||
|
опубликованной контрольной записи на целевые русскоязычные созвоны.
|
||||||
@@ -23,6 +23,7 @@ transcribe = "local_transcriber.cli:app"
|
|||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
"psutil>=7,<8",
|
||||||
"pytest",
|
"pytest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,851 @@
|
|||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
SCRIPT = (
|
||||||
|
Path(__file__).parents[1] / "scripts" / "benchmarks" / "diarization_calibration.py"
|
||||||
|
)
|
||||||
|
SPEC = importlib.util.spec_from_file_location("diarization_calibration", SCRIPT)
|
||||||
|
assert SPEC is not None and SPEC.loader is not None
|
||||||
|
calibration = importlib.util.module_from_spec(SPEC)
|
||||||
|
sys.modules[SPEC.name] = calibration
|
||||||
|
SPEC.loader.exec_module(calibration)
|
||||||
|
|
||||||
|
|
||||||
|
def _write(path, content):
|
||||||
|
path.write_bytes(content)
|
||||||
|
return {
|
||||||
|
"path": str(path),
|
||||||
|
"sha256": hashlib.sha256(content).hexdigest(),
|
||||||
|
"size_bytes": len(content),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest(tmp_path):
|
||||||
|
fp32 = _write(tmp_path / "fp32.onnx", b"fp32")
|
||||||
|
int8 = _write(tmp_path / "int8.onnx", b"int8")
|
||||||
|
wespeaker = _write(tmp_path / "wespeaker.onnx", b"wespeaker")
|
||||||
|
titanet = _write(tmp_path / "titanet.onnx", b"titanet")
|
||||||
|
segmentations = [
|
||||||
|
{
|
||||||
|
"id": "pyannote-fp32",
|
||||||
|
"precision": "fp32",
|
||||||
|
"source_url": "https://example/fp32",
|
||||||
|
"license": "MIT",
|
||||||
|
**fp32,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pyannote-int8",
|
||||||
|
"precision": "int8",
|
||||||
|
"source_url": "https://example/int8",
|
||||||
|
"license": "MIT",
|
||||||
|
**int8,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
embeddings = [
|
||||||
|
{
|
||||||
|
"id": "wespeaker",
|
||||||
|
"source_url": "https://example/wespeaker",
|
||||||
|
"license": "CC-BY-4.0",
|
||||||
|
**wespeaker,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "titanet-small",
|
||||||
|
"source_url": "https://example/titanet",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
**titanet,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
recordings = []
|
||||||
|
for index, (recording_id, speakers) in enumerate(
|
||||||
|
(("data-test", 3), ("t2-bdma", 2), ("yantar", 2))
|
||||||
|
):
|
||||||
|
media = _write(tmp_path / f"{recording_id}.mp4", recording_id.encode())
|
||||||
|
words_content = json.dumps(
|
||||||
|
[{"start": 0.0, "end": 0.5, "text": " Слово"}]
|
||||||
|
).encode()
|
||||||
|
words = _write(tmp_path / f"{recording_id}.words.json", words_content)
|
||||||
|
reference = None
|
||||||
|
if index:
|
||||||
|
reference_content = b"**[00:00] Speaker 1:**\n"
|
||||||
|
ref = _write(tmp_path / f"{recording_id}.md", reference_content)
|
||||||
|
reference = {
|
||||||
|
"path": ref["path"],
|
||||||
|
"sha256": ref["sha256"],
|
||||||
|
"format": "hypescribe_markdown",
|
||||||
|
}
|
||||||
|
recordings.append(
|
||||||
|
{
|
||||||
|
"id": recording_id,
|
||||||
|
"path": media["path"],
|
||||||
|
"sha256": media["sha256"],
|
||||||
|
"start": 0.0,
|
||||||
|
"duration": 10.0,
|
||||||
|
"expected_speakers": speakers,
|
||||||
|
"reference": reference,
|
||||||
|
"asr_words": {
|
||||||
|
"path": words["path"],
|
||||||
|
"sha256": words["sha256"],
|
||||||
|
"format": "local_transcriber_words_v1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"schema_version": 2,
|
||||||
|
"segmentation_models": segmentations,
|
||||||
|
"embedding_models": embeddings,
|
||||||
|
"combinations": [
|
||||||
|
{
|
||||||
|
"id": "fp32-wespeaker",
|
||||||
|
"segmentation_id": "pyannote-fp32",
|
||||||
|
"embedding_id": "wespeaker",
|
||||||
|
"role": "baseline",
|
||||||
|
"thresholds": [0.8, 0.9],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fp32-titanet",
|
||||||
|
"segmentation_id": "pyannote-fp32",
|
||||||
|
"embedding_id": "titanet-small",
|
||||||
|
"role": "candidate",
|
||||||
|
"thresholds": [0.8],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "int8-wespeaker",
|
||||||
|
"segmentation_id": "pyannote-int8",
|
||||||
|
"embedding_id": "wespeaker",
|
||||||
|
"role": "candidate",
|
||||||
|
"thresholds": [0.8],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "int8-titanet",
|
||||||
|
"segmentation_id": "pyannote-int8",
|
||||||
|
"embedding_id": "titanet-small",
|
||||||
|
"role": "candidate",
|
||||||
|
"thresholds": [0.8],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"recordings": recordings,
|
||||||
|
"threads": 4,
|
||||||
|
"rss_sample_interval_ms": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _environment():
|
||||||
|
return {
|
||||||
|
"sherpa_onnx_version": "1.13.5",
|
||||||
|
"onnxruntime_version": "1",
|
||||||
|
"numpy_version": "2",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _complete_cell(spec, clusters, purity=None, residual=0.0):
|
||||||
|
return {
|
||||||
|
**spec,
|
||||||
|
"status": "complete",
|
||||||
|
"diagnostics": {
|
||||||
|
"clusters": clusters,
|
||||||
|
"mapped_speaker_purity": purity,
|
||||||
|
"residual_share_after_expected": residual,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_validates_required_matrix_and_files(tmp_path):
|
||||||
|
assert calibration.validate_manifest(_manifest(tmp_path))["schema_version"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"mutation", ["missing_combination", "duplicate_id", "unsorted_threshold"]
|
||||||
|
)
|
||||||
|
def test_manifest_rejects_invalid_matrix_and_ids(tmp_path, mutation):
|
||||||
|
manifest = _manifest(tmp_path)
|
||||||
|
if mutation == "missing_combination":
|
||||||
|
manifest["combinations"].pop()
|
||||||
|
elif mutation == "duplicate_id":
|
||||||
|
manifest["recordings"][1]["id"] = manifest["recordings"][0]["id"]
|
||||||
|
else:
|
||||||
|
manifest["combinations"][0]["thresholds"] = [0.9, 0.8]
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
calibration.validate_manifest(manifest, verify_files=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_rejects_hash_mismatch_before_work(tmp_path):
|
||||||
|
manifest = _manifest(tmp_path)
|
||||||
|
manifest["recordings"][0]["sha256"] = "0" * 64
|
||||||
|
with pytest.raises(ValueError, match="SHA-256"):
|
||||||
|
calibration.validate_manifest(manifest)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("model_kind", ["segmentation", "embedding"])
|
||||||
|
def test_manifest_rejects_multiple_exact_model_ids_per_matrix_axis(
|
||||||
|
tmp_path, model_kind
|
||||||
|
):
|
||||||
|
manifest = _manifest(tmp_path)
|
||||||
|
if model_kind == "segmentation":
|
||||||
|
duplicate = dict(manifest["segmentation_models"][0])
|
||||||
|
duplicate["id"] = "pyannote-fp32-other"
|
||||||
|
manifest["segmentation_models"].append(duplicate)
|
||||||
|
manifest["combinations"][1]["segmentation_id"] = duplicate["id"]
|
||||||
|
else:
|
||||||
|
duplicate = dict(manifest["embedding_models"][0])
|
||||||
|
duplicate["id"] = "wespeaker-other"
|
||||||
|
manifest["embedding_models"].append(duplicate)
|
||||||
|
manifest["combinations"][2]["embedding_id"] = duplicate["id"]
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="ровно один"):
|
||||||
|
calibration.validate_manifest(manifest, verify_files=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_run_rejects_substitute_corpus(tmp_path):
|
||||||
|
manifest = _manifest(tmp_path)
|
||||||
|
with pytest.raises(ValueError, match="Корпус"):
|
||||||
|
calibration.validate_original_corpus(manifest)
|
||||||
|
for recording in manifest["recordings"]:
|
||||||
|
recording.update(calibration.ORIGINAL_CORPUS[recording["id"]])
|
||||||
|
calibration.validate_original_corpus(manifest)
|
||||||
|
|
||||||
|
|
||||||
|
def test_experiment_identity_ignores_paths_but_rejects_semantic_resume(tmp_path):
|
||||||
|
manifest = _manifest(tmp_path)
|
||||||
|
first = calibration.make_experiment_id(manifest, _environment())
|
||||||
|
moved = json.loads(json.dumps(manifest))
|
||||||
|
moved["recordings"][0]["path"] = "/different/private/path.mp4"
|
||||||
|
assert calibration.make_experiment_id(moved, _environment()) == first
|
||||||
|
output = tmp_path / "result.json"
|
||||||
|
calibration.save_output(output, {"schema_version": 2, "experiment_id": "f" * 64})
|
||||||
|
with pytest.raises(ValueError, match="другому семантическому"):
|
||||||
|
calibration._load_or_create_output(output, manifest, _environment(), first)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_rejects_different_measurement_host_without_path_identity(tmp_path):
|
||||||
|
manifest = _manifest(tmp_path)
|
||||||
|
first_environment = {
|
||||||
|
**_environment(),
|
||||||
|
"started_at_utc": "2026-08-29T00:00:00+00:00",
|
||||||
|
"platform": "host-a",
|
||||||
|
"cpu": "cpu-a",
|
||||||
|
"logical_cpus": 16,
|
||||||
|
"python_version": "3.13",
|
||||||
|
}
|
||||||
|
experiment_id = calibration.make_experiment_id(manifest, first_environment)
|
||||||
|
output_path = tmp_path / "result.json"
|
||||||
|
calibration.save_output(
|
||||||
|
output_path,
|
||||||
|
calibration._new_output(manifest, first_environment, experiment_id),
|
||||||
|
)
|
||||||
|
moved = json.loads(json.dumps(manifest))
|
||||||
|
moved["recordings"][0]["path"] = "/moved/private/recording.mp4"
|
||||||
|
assert calibration.make_experiment_id(moved, first_environment) == experiment_id
|
||||||
|
second_environment = {
|
||||||
|
**first_environment,
|
||||||
|
"started_at_utc": "2026-08-30T00:00:00+00:00",
|
||||||
|
"platform": "host-b",
|
||||||
|
"cpu": "cpu-b",
|
||||||
|
"logical_cpus": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="другой platform/cpu/logical_cpus"):
|
||||||
|
calibration._load_or_create_output(
|
||||||
|
output_path, moved, second_environment, experiment_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cell_expansion_is_rectangular_deterministic_and_path_independent(tmp_path):
|
||||||
|
manifest = _manifest(tmp_path)
|
||||||
|
experiment_id = calibration.make_experiment_id(manifest, _environment())
|
||||||
|
cells = calibration.expand_automatic_cells(manifest, experiment_id)
|
||||||
|
assert len(cells) == 15
|
||||||
|
assert [
|
||||||
|
(cell["combination_id"], cell["recording_id"], cell["threshold"])
|
||||||
|
for cell in cells[:4]
|
||||||
|
] == [
|
||||||
|
("fp32-wespeaker", "data-test", 0.8),
|
||||||
|
("fp32-wespeaker", "t2-bdma", 0.8),
|
||||||
|
("fp32-wespeaker", "yantar", 0.8),
|
||||||
|
("fp32-wespeaker", "data-test", 0.9),
|
||||||
|
]
|
||||||
|
assert all(cell["num_clusters"] == -1 for cell in cells)
|
||||||
|
moved = json.loads(json.dumps(manifest))
|
||||||
|
for model in moved["segmentation_models"] + moved["embedding_models"]:
|
||||||
|
model["path"] = f"/moved/models/{model['id']}.onnx"
|
||||||
|
for recording in moved["recordings"]:
|
||||||
|
recording["path"] = f"/moved/media/{recording['id']}.mp4"
|
||||||
|
recording["asr_words"]["path"] = f"/moved/words/{recording['id']}.json"
|
||||||
|
if recording["reference"]:
|
||||||
|
recording["reference"]["path"] = f"/moved/refs/{recording['id']}.md"
|
||||||
|
moved_experiment_id = calibration.make_experiment_id(moved, _environment())
|
||||||
|
assert moved_experiment_id == experiment_id
|
||||||
|
assert calibration.expand_automatic_cells(moved, moved_experiment_id) == cells
|
||||||
|
|
||||||
|
|
||||||
|
def test_threshold_selection_requires_complete_rectangle_and_uses_shared_gate(tmp_path):
|
||||||
|
manifest = _manifest(tmp_path)
|
||||||
|
combination = manifest["combinations"][0]
|
||||||
|
specs = calibration.expand_automatic_cells(manifest, "e" * 64)[:6]
|
||||||
|
assert calibration.select_threshold(combination, manifest["recordings"], []) is None
|
||||||
|
counts = [3, 2, 3, 3, 2, 2]
|
||||||
|
purities = [None, 0.7, 0.9, None, 0.8, 0.8]
|
||||||
|
cells = [
|
||||||
|
_complete_cell(spec, count, purity)
|
||||||
|
for spec, count, purity in zip(specs, counts, purities, strict=True)
|
||||||
|
]
|
||||||
|
selection = calibration.select_threshold(combination, manifest["recordings"], cells)
|
||||||
|
assert selection["selected_threshold"] == 0.9
|
||||||
|
assert selection["meets_count_gate"] is True
|
||||||
|
known = calibration.derive_known_cells(manifest, "e" * 64, selection)
|
||||||
|
assert [cell["num_clusters"] for cell in known] == [3, 2, 2]
|
||||||
|
assert {cell["threshold"] for cell in known} == {0.9}
|
||||||
|
|
||||||
|
|
||||||
|
def test_threshold_selection_records_failed_gate(tmp_path):
|
||||||
|
manifest = _manifest(tmp_path)
|
||||||
|
combination = manifest["combinations"][1]
|
||||||
|
specs = [
|
||||||
|
cell
|
||||||
|
for cell in calibration.expand_automatic_cells(manifest, "e" * 64)
|
||||||
|
if cell["combination_id"] == combination["id"]
|
||||||
|
]
|
||||||
|
cells = [
|
||||||
|
_complete_cell(spec, count, purity)
|
||||||
|
for spec, count, purity in zip(specs, [4, 3, 3], [None, 0.8, 0.8], strict=True)
|
||||||
|
]
|
||||||
|
assert (
|
||||||
|
calibration.select_threshold(combination, manifest["recordings"], cells)[
|
||||||
|
"meets_count_gate"
|
||||||
|
]
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stage_timing_parser_accepts_native_lines():
|
||||||
|
log = (
|
||||||
|
"[I] OfflineSpeakerDiarization: segmentation 1.000 s\n"
|
||||||
|
"[I] OfflineSpeakerDiarization: embedding 2.000 s\n"
|
||||||
|
"[I] OfflineSpeakerDiarization: clustering 0.100 s\n"
|
||||||
|
"[I] OfflineSpeakerDiarization: total 3.100 s, audio 10.000 s, RTF 0.310"
|
||||||
|
)
|
||||||
|
stages = calibration.parse_stage_timings(log, "1.13.5")
|
||||||
|
assert stages["engine_total_seconds"] == 3.1
|
||||||
|
assert stages["engine_audio_seconds"] == 10.0
|
||||||
|
assert stages["engine_rtf"] == 0.31
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"log",
|
||||||
|
[
|
||||||
|
"OfflineSpeakerDiarization: segmentation 1.0 s",
|
||||||
|
"OfflineSpeakerDiarization: segmentation 1.0 s\nOfflineSpeakerDiarization: segmentation 1.0 s\nOfflineSpeakerDiarization: embedding 1.0 s\nOfflineSpeakerDiarization: clustering 1.0 s\nOfflineSpeakerDiarization: total 3.0 s, audio 4 s, RTF .75",
|
||||||
|
"OfflineSpeakerDiarization: segmentation -1.0 s\nOfflineSpeakerDiarization: embedding 1.0 s\nOfflineSpeakerDiarization: clustering 1.0 s\nOfflineSpeakerDiarization: total 1.0 s, audio 4 s, RTF .25",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_stage_timing_parser_rejects_missing_duplicate_and_negative(log):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
calibration.parse_stage_timings(log, "1.13.5")
|
||||||
|
|
||||||
|
|
||||||
|
def test_stage_timing_parser_rejects_extra_malformed_record():
|
||||||
|
log = (
|
||||||
|
"OfflineSpeakerDiarization: segmentation ??? s\n"
|
||||||
|
"OfflineSpeakerDiarization: segmentation 1.000 s\n"
|
||||||
|
"OfflineSpeakerDiarization: embedding 2.000 s\n"
|
||||||
|
"OfflineSpeakerDiarization: clustering 0.100 s\n"
|
||||||
|
"OfflineSpeakerDiarization: total 3.100 s, audio 10.000 s, RTF 0.310"
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="повреждённую"):
|
||||||
|
calibration.parse_stage_timings(log, "1.13.5")
|
||||||
|
|
||||||
|
|
||||||
|
def test_measurement_uses_injected_cpu_clock_and_rss_once():
|
||||||
|
process = SimpleNamespace()
|
||||||
|
calls = []
|
||||||
|
cpu_times = iter(
|
||||||
|
[SimpleNamespace(user=1.0, system=2.0), SimpleNamespace(user=2.0, system=2.5)]
|
||||||
|
)
|
||||||
|
|
||||||
|
def read_cpu_times():
|
||||||
|
calls.append("cpu")
|
||||||
|
return next(cpu_times)
|
||||||
|
|
||||||
|
process.cpu_times = read_cpu_times
|
||||||
|
|
||||||
|
class Sampler:
|
||||||
|
def __init__(self, actual_process, interval):
|
||||||
|
assert actual_process is process
|
||||||
|
assert interval == 0.01
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
calls.append("start")
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
calls.append("stop")
|
||||||
|
return 1234
|
||||||
|
|
||||||
|
clock = iter([10.0, 12.0]).__next__
|
||||||
|
result, metrics = calibration.measure_call(
|
||||||
|
lambda: calls.append("process") or "ok",
|
||||||
|
process,
|
||||||
|
clock=clock,
|
||||||
|
sampler_factory=Sampler,
|
||||||
|
interval_seconds=0.01,
|
||||||
|
logical_cpus=4,
|
||||||
|
)
|
||||||
|
assert result == "ok"
|
||||||
|
assert calls == ["start", "cpu", "process", "cpu", "stop"]
|
||||||
|
assert metrics["cpu_total_seconds"] == 1.5
|
||||||
|
assert metrics["average_cpu_percent_machine"] == 18.75
|
||||||
|
assert metrics["peak_rss_bytes"] == 1234
|
||||||
|
|
||||||
|
|
||||||
|
def test_measurement_preserves_operation_error_when_sampler_stop_also_fails():
|
||||||
|
process = SimpleNamespace(
|
||||||
|
cpu_times=iter(
|
||||||
|
[
|
||||||
|
SimpleNamespace(user=1.0, system=2.0),
|
||||||
|
SimpleNamespace(user=1.1, system=2.1),
|
||||||
|
]
|
||||||
|
).__next__
|
||||||
|
)
|
||||||
|
|
||||||
|
class Sampler:
|
||||||
|
def __init__(self, process, interval):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
raise RuntimeError("sampler failed")
|
||||||
|
|
||||||
|
def operation():
|
||||||
|
raise ValueError("operation failed")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="operation failed") as error:
|
||||||
|
calibration.measure_call(
|
||||||
|
operation,
|
||||||
|
process,
|
||||||
|
clock=iter([1.0, 2.0]).__next__,
|
||||||
|
sampler_factory=Sampler,
|
||||||
|
interval_seconds=0.01,
|
||||||
|
logical_cpus=4,
|
||||||
|
)
|
||||||
|
assert any("sampler failed" in note for note in error.value.__notes__)
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_cache_identity_covers_hash_bounds_and_decoder():
|
||||||
|
recording = {"sha256": "a" * 64, "start": 1.0, "duration": 2.0}
|
||||||
|
original = calibration.decode_cache_key(recording)
|
||||||
|
assert calibration.decode_cache_key({**recording, "start": 2.0}) != original
|
||||||
|
assert calibration.decode_cache_key({**recording, "sha256": "b" * 64}) != original
|
||||||
|
assert calibration.decode_cache_key(recording, "another-decoder") != original
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_clip_declares_wav_format_for_temporary_output(tmp_path, monkeypatch):
|
||||||
|
recording = {
|
||||||
|
"id": "synthetic",
|
||||||
|
"path": str(tmp_path / "source.mp4"),
|
||||||
|
"sha256": "a" * 64,
|
||||||
|
"start": 1.0,
|
||||||
|
"duration": 2.0,
|
||||||
|
}
|
||||||
|
commands = []
|
||||||
|
|
||||||
|
def run(command, *, check):
|
||||||
|
assert check is True
|
||||||
|
commands.append(command)
|
||||||
|
Path(command[-1]).touch()
|
||||||
|
|
||||||
|
monkeypatch.setattr(calibration.subprocess, "run", run)
|
||||||
|
monkeypatch.setattr(calibration, "read_wav", lambda path: None)
|
||||||
|
|
||||||
|
output = calibration.decode_clip(recording, tmp_path / "work")
|
||||||
|
|
||||||
|
assert output.is_file()
|
||||||
|
assert not output.with_suffix(".wav.tmp").exists()
|
||||||
|
assert commands[0][-3:-1] == ["-f", "wav"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_hypescribe_parser_mapping_and_empty_diagnostics(tmp_path):
|
||||||
|
reference = tmp_path / "reference.md"
|
||||||
|
reference.write_text(
|
||||||
|
"**[00:10] Speaker 1:**\ntext\n**[00:15] Speaker 2:**\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
recording = {
|
||||||
|
"id": "clip",
|
||||||
|
"path": str(tmp_path / "clip.mp4"),
|
||||||
|
"sha256": "a" * 64,
|
||||||
|
"start": 12.0,
|
||||||
|
"duration": 6.0,
|
||||||
|
"expected_speakers": 2,
|
||||||
|
"reference": {"path": str(reference)},
|
||||||
|
}
|
||||||
|
turns = calibration.read_reference_turns(recording)
|
||||||
|
assert turns == [
|
||||||
|
{"speaker": "1", "start": 0.0, "end": 3.0},
|
||||||
|
{"speaker": "2", "start": 3.0, "end": 6.0},
|
||||||
|
]
|
||||||
|
mapping = calibration.best_mapping(
|
||||||
|
[
|
||||||
|
{"speaker": 7, "start": 0.0, "end": 3.0},
|
||||||
|
{"speaker": 8, "start": 3.0, "end": 6.0},
|
||||||
|
],
|
||||||
|
turns,
|
||||||
|
)
|
||||||
|
assert mapping["mapped_speaker_purity"] == 1.0
|
||||||
|
assert calibration.best_mapping([], turns) is None
|
||||||
|
summary = calibration.summarize_segments([], recording)
|
||||||
|
assert summary["clusters"] == 0 and summary["residual_share_after_expected"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_asr_sidecar_validation_text_invariance_and_unknown_words(tmp_path):
|
||||||
|
sidecar = tmp_path / "words.json"
|
||||||
|
sidecar.write_text(
|
||||||
|
json.dumps(
|
||||||
|
[
|
||||||
|
{"start": 0.0, "end": 0.5, "text": "Привет"},
|
||||||
|
{"start": 0.5, "end": 0.6, "text": "!"},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
words = calibration.load_asr_words(sidecar, 2.0)
|
||||||
|
recording = {"duration": 2.0, "expected_speakers": 1}
|
||||||
|
diagnostics, invariant = calibration.evaluate_segments(
|
||||||
|
[{"speaker": 0, "start": 0.0, "end": 0.5}], recording, words, []
|
||||||
|
)
|
||||||
|
assert diagnostics["unassigned_word_count"] == 1
|
||||||
|
assert diagnostics["mapped_speaker_purity"] is None
|
||||||
|
assert invariant["equal"] is True and invariant["first_mismatch_index"] is None
|
||||||
|
sidecar.write_text(
|
||||||
|
json.dumps(
|
||||||
|
[
|
||||||
|
{"start": 1.0, "end": 1.1, "text": "a"},
|
||||||
|
{"start": 0.5, "end": 0.6, "text": "b"},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="границы"):
|
||||||
|
calibration.load_asr_words(sidecar, 2.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_asr_sidecar_rejects_empty_baseline(tmp_path):
|
||||||
|
sidecar = tmp_path / "words.json"
|
||||||
|
sidecar.write_text("[]", encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="хотя бы одно слово"):
|
||||||
|
calibration.load_asr_words(sidecar, 300.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_invariance_records_first_mismatch_without_text():
|
||||||
|
transcript = SimpleNamespace(turns=[SimpleNamespace(text="другой")])
|
||||||
|
words = [SimpleNamespace(text="текст")]
|
||||||
|
evidence = calibration.text_invariance(words, transcript)
|
||||||
|
assert evidence["equal"] is False
|
||||||
|
assert evidence["first_mismatch_index"] == 0
|
||||||
|
assert "текст" not in json.dumps(evidence, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_invariance_ignores_unicode_whitespace_at_turn_boundaries():
|
||||||
|
words = [
|
||||||
|
SimpleNamespace(text="Вопрос?"),
|
||||||
|
SimpleNamespace(text=" — "),
|
||||||
|
SimpleNamespace(text="ответ"),
|
||||||
|
]
|
||||||
|
transcript = SimpleNamespace(
|
||||||
|
turns=[SimpleNamespace(text="Вопрос?"), SimpleNamespace(text="— ответ")]
|
||||||
|
)
|
||||||
|
|
||||||
|
evidence = calibration.text_invariance(words, transcript)
|
||||||
|
|
||||||
|
assert calibration.canonical_non_whitespace_sequence(words) == "Вопрос?—ответ"
|
||||||
|
assert evidence["equal"] is True
|
||||||
|
assert evidence["first_mismatch_index"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("output", ["Вопрос?ответ", "Вопрос!—ответ"])
|
||||||
|
def test_text_invariance_rejects_non_whitespace_deletion_or_substitution(output):
|
||||||
|
words = [SimpleNamespace(text="Вопрос?"), SimpleNamespace(text="— ответ")]
|
||||||
|
transcript = SimpleNamespace(turns=[SimpleNamespace(text=output)])
|
||||||
|
|
||||||
|
evidence = calibration.text_invariance(words, transcript)
|
||||||
|
|
||||||
|
assert evidence["equal"] is False
|
||||||
|
assert evidence["input_sha256"] != evidence["output_sha256"]
|
||||||
|
assert evidence["first_mismatch_index"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_atomic_persistence_leaves_no_temporary_file(tmp_path):
|
||||||
|
output = tmp_path / "nested" / "result.json"
|
||||||
|
calibration.save_output(output, {"private": "value"})
|
||||||
|
assert json.loads(output.read_text()) == {"private": "value"}
|
||||||
|
assert list(output.parent.glob("*.tmp")) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_atomic_persistence_preserves_target_and_cleans_temp_on_dump_failure(tmp_path):
|
||||||
|
output = tmp_path / "result.json"
|
||||||
|
calibration.save_output(output, {"stable": True})
|
||||||
|
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
calibration.save_output(output, {"unserializable": object()})
|
||||||
|
|
||||||
|
assert json.loads(output.read_text(encoding="utf-8")) == {"stable": True}
|
||||||
|
assert list(output.parent.glob("*.tmp")) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_failure_is_persisted_and_retried(tmp_path, monkeypatch):
|
||||||
|
output = {"cells": []}
|
||||||
|
output_path = tmp_path / "result.json"
|
||||||
|
spec = {
|
||||||
|
"cell_id": "cell",
|
||||||
|
"combination_id": "c",
|
||||||
|
"recording_id": "r",
|
||||||
|
"mode": "automatic",
|
||||||
|
"threshold": 0.8,
|
||||||
|
"num_clusters": -1,
|
||||||
|
}
|
||||||
|
attempts = []
|
||||||
|
|
||||||
|
def execute(*args, **kwargs):
|
||||||
|
attempts.append(1)
|
||||||
|
if len(attempts) == 1:
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
return {**spec, "status": "complete"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(calibration, "_execute_cell", execute)
|
||||||
|
calibration._run_specs([spec], {}, output, {}, output_path, tmp_path)
|
||||||
|
assert output["cells"][0]["status"] == "failed"
|
||||||
|
assert output["cells"][0]["metrics"] is None
|
||||||
|
calibration._run_specs([spec], {}, output, {}, output_path, tmp_path)
|
||||||
|
assert output["cells"][0]["status"] == "complete"
|
||||||
|
assert len(attempts) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_execute_cell_rejects_text_mismatch_without_persisting_private_text(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
manifest = _manifest(tmp_path)
|
||||||
|
output = {"environment": {"logical_cpus": 4, "sherpa_onnx_version": "1.13.5"}}
|
||||||
|
spec = calibration.expand_automatic_cells(manifest, "e" * 64)[0]
|
||||||
|
recording = manifest["recordings"][0]
|
||||||
|
private_text = " СОВЕРШЕННО-СЕКРЕТНЫЙ-ТЕКСТ"
|
||||||
|
Path(recording["asr_words"]["path"]).write_text(
|
||||||
|
json.dumps([{"start": 0.0, "end": 0.5, "text": private_text}]),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
calibration,
|
||||||
|
"run_worker",
|
||||||
|
lambda *args, **kwargs: (
|
||||||
|
{"metrics": {"wall_seconds": 1.0}, "segments": []},
|
||||||
|
(
|
||||||
|
"OfflineSpeakerDiarization: segmentation 0.300 s\n"
|
||||||
|
"OfflineSpeakerDiarization: embedding 0.600 s\n"
|
||||||
|
"OfflineSpeakerDiarization: clustering 0.100 s\n"
|
||||||
|
"OfflineSpeakerDiarization: total 1.000 s, "
|
||||||
|
"audio 10.000 s, RTF 0.100"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(calibration, "read_reference_turns", lambda *args: [])
|
||||||
|
|
||||||
|
def evaluate(segments, recording, words, reference_turns):
|
||||||
|
source = calibration.canonical_non_whitespace_sequence(words)
|
||||||
|
assert private_text.strip() in source
|
||||||
|
output = source[:-1]
|
||||||
|
return {}, {
|
||||||
|
"input_sha256": hashlib.sha256(source.encode()).hexdigest(),
|
||||||
|
"output_sha256": hashlib.sha256(output.encode()).hexdigest(),
|
||||||
|
"equal": False,
|
||||||
|
"first_mismatch_index": len(output),
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(calibration, "evaluate_segments", evaluate)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="first_mismatch_index=") as error:
|
||||||
|
calibration._execute_cell(
|
||||||
|
spec,
|
||||||
|
manifest,
|
||||||
|
output,
|
||||||
|
{recording["id"]: tmp_path / "decoded.wav"},
|
||||||
|
tmp_path,
|
||||||
|
)
|
||||||
|
assert private_text.strip() not in str(error.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_execute_cell_rejects_engine_audio_duration_mismatch(tmp_path, monkeypatch):
|
||||||
|
manifest = _manifest(tmp_path)
|
||||||
|
output = {"environment": {"logical_cpus": 4, "sherpa_onnx_version": "1.13.5"}}
|
||||||
|
spec = calibration.expand_automatic_cells(manifest, "e" * 64)[0]
|
||||||
|
recording = manifest["recordings"][0]
|
||||||
|
monkeypatch.setattr(
|
||||||
|
calibration,
|
||||||
|
"run_worker",
|
||||||
|
lambda *args, **kwargs: (
|
||||||
|
{"metrics": {"wall_seconds": 1.0}, "segments": []},
|
||||||
|
(
|
||||||
|
"OfflineSpeakerDiarization: segmentation 0.300 s\n"
|
||||||
|
"OfflineSpeakerDiarization: embedding 0.600 s\n"
|
||||||
|
"OfflineSpeakerDiarization: clustering 0.100 s\n"
|
||||||
|
"OfflineSpeakerDiarization: total 1.000 s, "
|
||||||
|
"audio 9.000 s, RTF 0.111"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="Длительность audio"):
|
||||||
|
calibration._execute_cell(
|
||||||
|
spec,
|
||||||
|
manifest,
|
||||||
|
output,
|
||||||
|
{recording["id"]: tmp_path / "decoded.wav"},
|
||||||
|
tmp_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_fails_closed_when_a_cell_failed(tmp_path, monkeypatch):
|
||||||
|
manifest = _manifest(tmp_path)
|
||||||
|
for recording in manifest["recordings"]:
|
||||||
|
recording.update(calibration.ORIGINAL_CORPUS[recording["id"]])
|
||||||
|
manifest_path = tmp_path / "manifest.json"
|
||||||
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
output_path = tmp_path / "result.json"
|
||||||
|
work_dir = tmp_path / "work"
|
||||||
|
monkeypatch.setattr(calibration, "validate_manifest", lambda *args, **kwargs: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
calibration,
|
||||||
|
"environment_snapshot",
|
||||||
|
lambda: {
|
||||||
|
**_environment(),
|
||||||
|
"started_at_utc": "2026-08-29T00:00:00+00:00",
|
||||||
|
"platform": "test",
|
||||||
|
"cpu": "test",
|
||||||
|
"logical_cpus": 4,
|
||||||
|
"python_version": "3.13",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
calibration,
|
||||||
|
"decode_clip",
|
||||||
|
lambda recording, target: tmp_path / f"{recording['id']}.wav",
|
||||||
|
)
|
||||||
|
|
||||||
|
def run_specs(specs, manifest, output, decoded, output_path, work_dir):
|
||||||
|
for spec in specs:
|
||||||
|
calibration._replace_cell(
|
||||||
|
output, calibration._failed_cell(spec, RuntimeError("boom"))
|
||||||
|
)
|
||||||
|
calibration.save_output(output_path, output)
|
||||||
|
|
||||||
|
monkeypatch.setattr(calibration, "_run_specs", run_specs)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="незавершённых ячеек 15"):
|
||||||
|
calibration.main(
|
||||||
|
[
|
||||||
|
"--manifest",
|
||||||
|
str(manifest_path),
|
||||||
|
"--output",
|
||||||
|
str(output_path),
|
||||||
|
"--work-dir",
|
||||||
|
str(work_dir),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_fails_closed_for_complete_rectangle_without_mapped_purity(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
manifest = _manifest(tmp_path)
|
||||||
|
for recording in manifest["recordings"]:
|
||||||
|
recording.update(calibration.ORIGINAL_CORPUS[recording["id"]])
|
||||||
|
manifest_path = tmp_path / "manifest.json"
|
||||||
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
output_path = tmp_path / "result.json"
|
||||||
|
work_dir = tmp_path / "work"
|
||||||
|
monkeypatch.setattr(calibration, "validate_manifest", lambda *args, **kwargs: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
calibration,
|
||||||
|
"environment_snapshot",
|
||||||
|
lambda: {
|
||||||
|
**_environment(),
|
||||||
|
"started_at_utc": "2026-08-29T00:00:00+00:00",
|
||||||
|
"platform": "test",
|
||||||
|
"cpu": "test",
|
||||||
|
"logical_cpus": 4,
|
||||||
|
"python_version": "3.13",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
calibration,
|
||||||
|
"decode_clip",
|
||||||
|
lambda recording, target: tmp_path / f"{recording['id']}.wav",
|
||||||
|
)
|
||||||
|
modes = []
|
||||||
|
|
||||||
|
def run_specs(specs, manifest, output, decoded, output_path, work_dir):
|
||||||
|
modes.extend({spec["mode"] for spec in specs})
|
||||||
|
expected = {
|
||||||
|
recording["id"]: recording["expected_speakers"]
|
||||||
|
for recording in manifest["recordings"]
|
||||||
|
}
|
||||||
|
for spec in specs:
|
||||||
|
calibration._replace_cell(
|
||||||
|
output,
|
||||||
|
_complete_cell(spec, expected[spec["recording_id"]], purity=None),
|
||||||
|
)
|
||||||
|
calibration.save_output(output_path, output)
|
||||||
|
|
||||||
|
monkeypatch.setattr(calibration, "_run_specs", run_specs)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="свип непригоден"):
|
||||||
|
calibration.main(
|
||||||
|
[
|
||||||
|
"--manifest",
|
||||||
|
str(manifest_path),
|
||||||
|
"--output",
|
||||||
|
str(output_path),
|
||||||
|
"--work-dir",
|
||||||
|
str(work_dir),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
output = json.loads(output_path.read_text(encoding="utf-8"))
|
||||||
|
assert modes == ["automatic"]
|
||||||
|
assert {cell["mode"] for cell in output["cells"]} == {"automatic"}
|
||||||
|
assert output["threshold_selection"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_worker_fake_protocol_reports_failure_and_success(tmp_path):
|
||||||
|
request = {"cell_id": "abc"}
|
||||||
|
commands = []
|
||||||
|
|
||||||
|
def failed(command, **kwargs):
|
||||||
|
commands.append(command)
|
||||||
|
return SimpleNamespace(returncode=1, stdout="", stderr="worker failed\n")
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="worker failed"):
|
||||||
|
calibration.run_worker(request, tmp_path, runner=failed)
|
||||||
|
|
||||||
|
def success(command, **kwargs):
|
||||||
|
commands.append(command)
|
||||||
|
return SimpleNamespace(
|
||||||
|
returncode=0,
|
||||||
|
stdout='{"metrics":{},"segments":[]}',
|
||||||
|
stderr="native log",
|
||||||
|
)
|
||||||
|
|
||||||
|
payload, stderr = calibration.run_worker(request, tmp_path, runner=success)
|
||||||
|
assert payload["segments"] == [] and stderr == "native log"
|
||||||
|
for command in commands:
|
||||||
|
assert command[:3] == [
|
||||||
|
sys.executable,
|
||||||
|
str(SCRIPT.resolve()),
|
||||||
|
"--worker",
|
||||||
|
]
|
||||||
|
request_path = Path(command[3])
|
||||||
|
assert request_path.is_file()
|
||||||
|
assert json.loads(request_path.read_text(encoding="utf-8")) == request
|
||||||
@@ -279,6 +279,7 @@ dependencies = [
|
|||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "psutil" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -297,7 +298,10 @@ requires-dist = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [{ name = "pytest" }]
|
dev = [
|
||||||
|
{ name = "psutil", specifier = ">=7,<8" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markdown-it-py"
|
name = "markdown-it-py"
|
||||||
@@ -521,6 +525,22 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psutil"
|
||||||
|
version = "7.2.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.169Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.977Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.795Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.343Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.515Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
version = "9.0.2"
|
version = "9.0.2"
|
||||||
|
|||||||
Reference in new issue
Block a user