docs(diarization): добавлены отчёт и скрипт калибровки
- Зачем: - необходим устойчивый дефолт модели эмбеддингов и порога на русской речи. - Что: - задокументирован выбор WeSpeaker ResNet34 LM с порогом 0,89. - добавлен возобновляемый скрипт свипа моделей и параметров диаризации. - Проверка: - uvx --cache-dir .uv-cache ruff check scripts/benchmarks/diarization_calibration.py. - uv run --cache-dir .uv-cache pytest: 243 passed, 1 skipped.
This commit is contained in:
@@ -0,0 +1,209 @@
|
|||||||
|
# Калибровка модели эмбеддингов и порога диаризации
|
||||||
|
|
||||||
|
**Дата:** 2026-08-14
|
||||||
|
|
||||||
|
**Статус:** выбор конфигурации для проектирования. Не приёмка
|
||||||
|
производительности на целевом Intel Core i5 11-го поколения.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Для автоматического определения числа участников использовать:
|
||||||
|
|
||||||
|
- эмбеддинги `wespeaker_en_voxceleb_resnet34_LM.onnx`;
|
||||||
|
- `FastClusteringConfig.threshold=0.89`;
|
||||||
|
- `num_clusters=-1` по умолчанию.
|
||||||
|
|
||||||
|
Если число участников известно, передавать его через `num_clusters`: это
|
||||||
|
устраняет остаточные кластеры и служит страховкой от особенностей записи. На
|
||||||
|
трёх проверенных фрагментах явное число участников не ухудшило прокси-метрику
|
||||||
|
качества WeSpeaker.
|
||||||
|
|
||||||
|
Двуязычная `3dspeaker_speech_campplus_sv_zh_en_16k-common_advanced.onnx`
|
||||||
|
быстрее и при известном числе участников лучше на одной из двух записей с
|
||||||
|
таймкодами, но для автоматического режима не нашлось общего порога без лишних
|
||||||
|
кластеров или склейки реальных голосов. Поэтому она не выбрана по умолчанию.
|
||||||
|
|
||||||
|
## Что проверялось
|
||||||
|
|
||||||
|
Свип выполнялся на трёх русскоязычных рабочих созвонах с известным составом:
|
||||||
|
|
||||||
|
| Запись | Участников | Короткий фрагмент | Полный прогон кандидата | SHA-256 |
|
||||||
|
|---|---:|---:|---:|---|
|
||||||
|
| `2026-07-10 Data Test внутренний статус.mp4` | 3 | 07:00–12:00 | 25:59,9 | `1057616B42E8ADD00E0EB975B02BDEF0EC9F6CDFEC6DBF488E0C60423C9B7B87` |
|
||||||
|
| `2026-07-29 T2 BDMA уточнение задачи от Ильи.mp4` | 2 | 00:00–05:00 | 14:50,9 | `51866D247FE3EDA134CDD884F707B1F1DB8855B492E8BD14D2B56B62476255ED` |
|
||||||
|
| `2026-08-12 Созвон с Максом Мерлином по T2 Forecast и Yantar.mp4` | 2 | 00:00–05:00 | 20:22,2 | `4422F04E2771091A0648E5422D14A31DD7CA2C8EEF7ED9F4A5C63F43D8CA6400` |
|
||||||
|
|
||||||
|
Для первой записи число участников взято из согласованного MoM и не зависит от
|
||||||
|
диаризации. Для двух остальных рядом с медиа лежат транскрипты Hypescribe с
|
||||||
|
таймкодами и метками спикеров. Они получены другим инструментом и использованы
|
||||||
|
как независимая грубая опорная разметка.
|
||||||
|
|
||||||
|
Hypescribe ставит метку только в начале реплики и не размечает точные границы,
|
||||||
|
тишину и наложения голосов. Поэтому ниже считается не DER, а **mapped speaker
|
||||||
|
purity**: лучший взаимно-однозначный маппинг кластеров на опорные метки по
|
||||||
|
суммарному перекрытию. Метрика подходит для сравнения конфигураций на одной
|
||||||
|
записи, но не является абсолютной оценкой диаризации.
|
||||||
|
|
||||||
|
Кластер считается содержательным, если в нём не меньше `max(5 с, 2% длины
|
||||||
|
записи)` речи. Это только диагностический показатель: готовый CLI не должен
|
||||||
|
молча отбрасывать малые кластеры без отдельного решения.
|
||||||
|
|
||||||
|
## Модели
|
||||||
|
|
||||||
|
Во всех прогонах использовалась одна сегментация
|
||||||
|
`sherpa-onnx-pyannote-segmentation-3-0`.
|
||||||
|
|
||||||
|
| Роль | Модель | Языки обучения | Размер | SHA-256 |
|
||||||
|
|---|---|---|---:|---|
|
||||||
|
| выбранная | `wespeaker_en_voxceleb_resnet34_LM.onnx` | английский, VoxCeleb2 | 26 530 550 | `E9848563DA86F263117134DFD7AD63C92355B37DE492B55E325400C9D9C39012` |
|
||||||
|
| многоязычная альтернатива | `3dspeaker_speech_campplus_sv_zh_en_16k-common_advanced.onnx` | китайский + английский | 28 281 164 | `AA3CFC16963A10586A9393F5035D6D6B57E98D358B347F80C2A30BF4F00CEBA2` |
|
||||||
|
| дополнительная разведка | `3dspeaker_speech_eres2net_base_sv_zh-cn_3dspeaker_16k.onnx` | китайский | 39 593 761 | `1A331345F04805BADBB495C775A6DDFFCDD1A732567D5EC8B3D5749E3C7A5E4B` |
|
||||||
|
| сегментация | `model.onnx` из `sherpa-onnx-pyannote-segmentation-3-0` | — | 5 992 913 | `220AD67CA923BEF2FA91F2390C786097BF305BCEB5E261D4AF67B38E938E1079` |
|
||||||
|
|
||||||
|
WeSpeaker сам помечает VoxCeleb-модель как английскую и распространяет её под
|
||||||
|
CC BY 4.0. Репозиторий 3D-Speaker и модель CAMPPlus на ModelScope используют
|
||||||
|
Apache 2.0; в исходниках 3D-Speaker модель явно описана как обученная на
|
||||||
|
большом китайско-английском корпусе. ONNX-файлы брались из официального релиза
|
||||||
|
`k2-fsa/sherpa-onnx`, а не из сторонних зеркал.
|
||||||
|
|
||||||
|
Источники:
|
||||||
|
|
||||||
|
- [список и лицензирование моделей WeSpeaker](https://github.com/wenet-e2e/wespeaker/blob/master/docs/pretrained.md);
|
||||||
|
- [карточка `wespeaker-voxceleb-resnet34-LM`](https://huggingface.co/Wespeaker/wespeaker-voxceleb-resnet34-LM);
|
||||||
|
- [исходники и лицензия 3D-Speaker](https://github.com/modelscope/3D-Speaker);
|
||||||
|
- [официальный релиз ONNX-моделей sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx/releases/tag/speaker-recongition-models).
|
||||||
|
|
||||||
|
## Свип WeSpeaker
|
||||||
|
|
||||||
|
Порог сначала проверялся крупным шагом, затем уточнялся около переходов между
|
||||||
|
числом кластеров. В ячейках — общее число кластеров; жирным выделено точное
|
||||||
|
совпадение с известным числом участников.
|
||||||
|
|
||||||
|
| Порог | Data Test, 3 | T2 BDMA, 2 | Yantar, 2 |
|
||||||
|
|---:|---:|---:|---:|
|
||||||
|
| 0,85 | **3** | **2** | 3 |
|
||||||
|
| 0,87 | **3** | **2** | 3 |
|
||||||
|
| 0,88 | **3** | **2** | 3 |
|
||||||
|
| **0,89** | **3** | **2** | **2** |
|
||||||
|
| 0,90 | 2 | **2** | **2** |
|
||||||
|
| 0,95 | 2 | **2** | 1 |
|
||||||
|
| явное `num_clusters` | **3** | **2** | **2** |
|
||||||
|
|
||||||
|
`0,89` — единственное проверенное значение, которое без знания числа
|
||||||
|
участников дало правильное количество кластеров на всех трёх фрагментах. На
|
||||||
|
двух записях с опорными метками purity составила 0,767 и 0,787. Явное число
|
||||||
|
участников дало те же значения.
|
||||||
|
|
||||||
|
## Сравнение с 3D-Speaker
|
||||||
|
|
||||||
|
### CAMPPlus, китайский + английский
|
||||||
|
|
||||||
|
| Порог | Data Test, 3 | T2 BDMA, 2 | Yantar, 2 |
|
||||||
|
|---:|---:|---:|---:|
|
||||||
|
| 0,85 | 7 | 7 | 7 |
|
||||||
|
| 0,90 | 6 | 5 | 5 |
|
||||||
|
| 0,95 | 5 | 5 | 5 |
|
||||||
|
| 0,99 | 4 | 4 | 4 |
|
||||||
|
| 1,00 | 4 | 4 | 4 |
|
||||||
|
| 1,05 | **3** | 3 | 3 |
|
||||||
|
| 1,10 | 2 | **2** | 3 |
|
||||||
|
| явное `num_clusters` | **3** | **2** | **2** |
|
||||||
|
|
||||||
|
При `1,05` на двух записях остаётся по одному малому остаточному кластеру, а
|
||||||
|
при `1,10` трёхсторонняя встреча уже склеивается до двух голосов. Общего
|
||||||
|
автоматического порога нет.
|
||||||
|
|
||||||
|
При явном числе участников purity равна 0,801 на T2 BDMA и 0,911 на Yantar.
|
||||||
|
Это лучше WeSpeaker на 0,034 и 0,124 соответственно. Однако на контрольной
|
||||||
|
трёхсторонней записи один из трёх принудительных кластеров оказался меньше
|
||||||
|
порога содержательности, поэтому улучшение по двум текстовым прокси нельзя
|
||||||
|
обобщать на все записи.
|
||||||
|
|
||||||
|
### ERes2Net base, китайский
|
||||||
|
|
||||||
|
Эта модель проверялась дополнительно, но не считается выполнением требования
|
||||||
|
о многоязычной альтернативе. Даже на пороге 0,99 она дала 5 / 4 / 7 кластеров
|
||||||
|
вместо 3 / 2 / 2. При явном числе участников purity составила 0,688 и 0,907:
|
||||||
|
результат неоднородный и автоматический режим заметно хуже выбранного.
|
||||||
|
|
||||||
|
## Полные прогоны выбранного кандидата
|
||||||
|
|
||||||
|
После свипа `WeSpeaker + 0,89` прогнан на всех трёх записях целиком.
|
||||||
|
|
||||||
|
| Запись | Кластеры | Содержательные | Речь по кластерам, с | Остаток сверх ожидаемых | Purity | Время | RTF |
|
||||||
|
|---|---:|---:|---|---:|---:|---:|---:|
|
||||||
|
| Data Test | 4 | 3 | 573,1 / 487,4 / 342,2 / 19,1 | 1,3% | — | 189,0 с | 0,121 |
|
||||||
|
| T2 BDMA | 2 | 2 | 748,7 / 52,3 | 0% | 0,749 | 112,3 с | 0,126 |
|
||||||
|
| Yantar | 2 | 2 | 733,2 / 259,8 | 0% | 0,906 | 151,4 с | 0,124 |
|
||||||
|
|
||||||
|
На полной контрольной записи остаётся ложный кластер на 19,1 с, но три
|
||||||
|
содержательных кластера совпадают с известным составом. Повторный полный прогон
|
||||||
|
на 0,9 дал тот же результат: JSON-массивы всех 340 интервалов на 0,89 и 0,9
|
||||||
|
совпали в точности, включая границы и номера кластеров. Поэтому к кандидату
|
||||||
|
0,89 непосредственно применима слуховая проверка отрезка 07:00–12:00,
|
||||||
|
выполненная для результата из
|
||||||
|
[разведочного замера](2026-08-12-diarization-feasibility.md): три основных
|
||||||
|
голоса стабильны, остаточный кластер ложный, есть небольшие пропуски второго
|
||||||
|
голоса, а наложения голосов определяются не полностью. Новая калибровка не
|
||||||
|
устраняет эти ограничения сегментации.
|
||||||
|
|
||||||
|
На двух полных разговорах purity отличается от короткого фрагмента: 0,749
|
||||||
|
против 0,767 и 0,906 против 0,787. Это подтверждает, что короткий свип годится
|
||||||
|
для отсева конфигураций, а финальный кандидат надо проверять целиком.
|
||||||
|
|
||||||
|
## Производительность
|
||||||
|
|
||||||
|
Условия: AMD Ryzen 7 8845H, Windows 11 build 26200, Python 3.13.13,
|
||||||
|
`sherpa-onnx` 1.13.5, `onnxruntime` 1.28.0, NumPy 2.4.3, 8 потоков CPU.
|
||||||
|
Загрузка моделей и декодирование медиа не входят в измерение.
|
||||||
|
|
||||||
|
Средний RTF на коротких фрагментах:
|
||||||
|
|
||||||
|
| Модель | RTF | Относительно WeSpeaker |
|
||||||
|
|---|---:|---:|
|
||||||
|
| WeSpeaker ResNet34 LM | 0,118 | 1,00× |
|
||||||
|
| CAMPPlus zh/en | 0,083 | 0,70× |
|
||||||
|
| ERes2Net base zh | 0,152 | 1,29× |
|
||||||
|
|
||||||
|
CAMPPlus примерно на 30% быстрее WeSpeaker в этом эксперименте. Это плюс для
|
||||||
|
варианта с известным числом участников, но замер на AMD не заменяет приёмку на
|
||||||
|
целевом Intel Core i5 11-го поколения.
|
||||||
|
|
||||||
|
## Воспроизводимость
|
||||||
|
|
||||||
|
Свип выполняется скриптом
|
||||||
|
[`scripts/benchmarks/diarization_calibration.py`](../../scripts/benchmarks/diarization_calibration.py).
|
||||||
|
Он принимает JSON-манифест с путями к моделям и записям, декодирует указанные
|
||||||
|
фрагменты через ffmpeg, последовательно сохраняет каждый результат и может
|
||||||
|
возобновить прерванный прогон.
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run python scripts/benchmarks/diarization_calibration.py `
|
||||||
|
--manifest diarization-calibration.json `
|
||||||
|
--output diarization-calibration-results.json `
|
||||||
|
--work-dir .scratch/diarization-calibration `
|
||||||
|
--threads 8
|
||||||
|
```
|
||||||
|
|
||||||
|
Сырые JSON содержат локальные пути к конфиденциальным рабочим записям и сами
|
||||||
|
интервалы диаризации, поэтому в репозиторий не добавляются. Для проверки
|
||||||
|
артефактов выше приведены SHA-256 медиа и моделей.
|
||||||
|
|
||||||
|
## Ограничения и следующий шаг
|
||||||
|
|
||||||
|
- Три записи принадлежат одному типу русскоязычных рабочих созвонов; это не
|
||||||
|
репрезентативная выборка для всех микрофонов, шумов и акцентов.
|
||||||
|
- Опорные метки двух записей грубые и не дают посчитать DER.
|
||||||
|
- На слух проверен только фрагмент 07:00–12:00 контрольной записи; перед
|
||||||
|
выпуском нужен слуховой контроль плотного диалога на финальной сборке.
|
||||||
|
- Калибровка выбирает эмбеддинги и кластеризацию, но не решает ошибки границ и
|
||||||
|
неполное распознавание наложений голосов.
|
||||||
|
- Производительность должна отдельно приниматься на целевом Intel Core i5.
|
||||||
|
|
||||||
|
Для спецификации зафиксировать WeSpeaker + 0,89 как автоматический дефолт,
|
||||||
|
отдельную опцию явного числа участников и отсутствие автоматического
|
||||||
|
отбрасывания малых кластеров. CAMPPlus zh/en можно оставить кандидатом для
|
||||||
|
будущего режима с обязательным `num_clusters` после расширенной слуховой
|
||||||
|
проверки.
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
"""Воспроизводимый свип параметров офлайн-диаризации sherpa-onnx."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import itertools
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import wave
|
||||||
|
from collections import defaultdict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import sherpa_onnx
|
||||||
|
|
||||||
|
TURN_RE = re.compile(r"^\*\*\[(\d{2}):(\d{2})(?::(\d{2}))?\] Speaker (\d+):\*\*")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Recording:
|
||||||
|
name: str
|
||||||
|
path: Path
|
||||||
|
start: float
|
||||||
|
duration: float
|
||||||
|
expected_speakers: int
|
||||||
|
reference: Path | None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--manifest", type=Path, required=True)
|
||||||
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
|
parser.add_argument("--work-dir", type=Path, required=True)
|
||||||
|
parser.add_argument("--threads", type=int, default=8)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def file_sha256(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as source:
|
||||||
|
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest().upper()
|
||||||
|
|
||||||
|
|
||||||
|
def decode_clip(recording: Recording, work_dir: Path) -> Path:
|
||||||
|
output = work_dir / f"{recording.name}.wav"
|
||||||
|
if output.exists():
|
||||||
|
return output
|
||||||
|
|
||||||
|
command = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-y",
|
||||||
|
"-ss",
|
||||||
|
str(recording.start),
|
||||||
|
"-t",
|
||||||
|
str(recording.duration),
|
||||||
|
"-i",
|
||||||
|
str(recording.path),
|
||||||
|
"-vn",
|
||||||
|
"-ac",
|
||||||
|
"1",
|
||||||
|
"-ar",
|
||||||
|
"16000",
|
||||||
|
"-c:a",
|
||||||
|
"pcm_s16le",
|
||||||
|
str(output),
|
||||||
|
]
|
||||||
|
subprocess.run(command, check=True)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def read_wav(path: Path) -> np.ndarray:
|
||||||
|
with wave.open(str(path), "rb") as source:
|
||||||
|
if source.getnchannels() != 1 or source.getsampwidth() != 2:
|
||||||
|
raise ValueError(f"Ожидался mono PCM16 WAV: {path}")
|
||||||
|
if source.getframerate() != 16000:
|
||||||
|
raise ValueError(f"Ожидалась частота 16 кГц: {path}")
|
||||||
|
samples = np.frombuffer(source.readframes(source.getnframes()), np.int16)
|
||||||
|
return samples.astype(np.float32) / 32768.0
|
||||||
|
|
||||||
|
|
||||||
|
def timestamp_seconds(match: re.Match[str]) -> float:
|
||||||
|
first, second, third = match.group(1), match.group(2), match.group(3)
|
||||||
|
if third is None:
|
||||||
|
return int(first) * 60 + int(second)
|
||||||
|
return int(first) * 3600 + int(second) * 60 + int(third)
|
||||||
|
|
||||||
|
|
||||||
|
def read_reference_turns(recording: Recording) -> list[dict[str, Any]]:
|
||||||
|
if recording.reference is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
starts: list[tuple[float, str]] = []
|
||||||
|
for line in recording.reference.read_text(encoding="utf-8").splitlines():
|
||||||
|
match = TURN_RE.match(line)
|
||||||
|
if match:
|
||||||
|
starts.append((timestamp_seconds(match), match.group(4)))
|
||||||
|
|
||||||
|
clip_end = recording.start + recording.duration
|
||||||
|
turns: list[dict[str, Any]] = []
|
||||||
|
for index, (start, speaker) in enumerate(starts):
|
||||||
|
end = starts[index + 1][0] if index + 1 < len(starts) else clip_end
|
||||||
|
overlap_start = max(start, recording.start)
|
||||||
|
overlap_end = min(end, clip_end)
|
||||||
|
if overlap_end > overlap_start:
|
||||||
|
turns.append(
|
||||||
|
{
|
||||||
|
"speaker": speaker,
|
||||||
|
"start": overlap_start - recording.start,
|
||||||
|
"end": overlap_end - recording.start,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return turns
|
||||||
|
|
||||||
|
|
||||||
|
def interval_overlap(left: dict[str, Any], right: dict[str, Any]) -> float:
|
||||||
|
return max(0.0, min(left["end"], right["end"]) - max(left["start"], right["start"]))
|
||||||
|
|
||||||
|
|
||||||
|
def best_mapping(
|
||||||
|
segments: list[dict[str, Any]],
|
||||||
|
reference_turns: list[dict[str, Any]],
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
if not reference_turns or not segments:
|
||||||
|
return None
|
||||||
|
|
||||||
|
predicted = sorted({str(segment["speaker"]) for segment in segments})
|
||||||
|
reference = sorted({str(turn["speaker"]) for turn in reference_turns})
|
||||||
|
overlap: defaultdict[tuple[str, str], float] = defaultdict(float)
|
||||||
|
total = 0.0
|
||||||
|
for segment in segments:
|
||||||
|
predicted_speaker = str(segment["speaker"])
|
||||||
|
for turn in reference_turns:
|
||||||
|
value = interval_overlap(segment, turn)
|
||||||
|
if value:
|
||||||
|
reference_speaker = str(turn["speaker"])
|
||||||
|
overlap[(predicted_speaker, reference_speaker)] += value
|
||||||
|
total += value
|
||||||
|
|
||||||
|
best_score = -1.0
|
||||||
|
best_pairs: list[tuple[str, str]] = []
|
||||||
|
if len(predicted) >= len(reference):
|
||||||
|
for candidate in itertools.permutations(predicted, len(reference)):
|
||||||
|
pairs = list(zip(candidate, reference, strict=True))
|
||||||
|
score = sum(overlap[pair] for pair in pairs)
|
||||||
|
if score > best_score:
|
||||||
|
best_score, best_pairs = score, pairs
|
||||||
|
else:
|
||||||
|
for candidate in itertools.permutations(reference, len(predicted)):
|
||||||
|
pairs = list(zip(predicted, candidate, strict=True))
|
||||||
|
score = sum(overlap[pair] for pair in pairs)
|
||||||
|
if score > best_score:
|
||||||
|
best_score, best_pairs = score, pairs
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mapped_speaker_purity": best_score / total if total else None,
|
||||||
|
"mapped_overlap_seconds": best_score,
|
||||||
|
"total_overlap_seconds": total,
|
||||||
|
"mapping": {predicted: reference for predicted, reference in best_pairs},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_config(
|
||||||
|
segmentation_model: Path,
|
||||||
|
embedding_model: Path,
|
||||||
|
threshold: float,
|
||||||
|
num_clusters: int,
|
||||||
|
threads: int,
|
||||||
|
) -> sherpa_onnx.OfflineSpeakerDiarizationConfig:
|
||||||
|
pyannote = sherpa_onnx.OfflineSpeakerSegmentationPyannoteModelConfig(
|
||||||
|
model=str(segmentation_model)
|
||||||
|
)
|
||||||
|
segmentation = sherpa_onnx.OfflineSpeakerSegmentationModelConfig(
|
||||||
|
pyannote=pyannote,
|
||||||
|
num_threads=threads,
|
||||||
|
)
|
||||||
|
embedding = sherpa_onnx.SpeakerEmbeddingExtractorConfig(
|
||||||
|
model=str(embedding_model),
|
||||||
|
num_threads=threads,
|
||||||
|
)
|
||||||
|
clustering = sherpa_onnx.FastClusteringConfig(
|
||||||
|
num_clusters=num_clusters,
|
||||||
|
threshold=threshold,
|
||||||
|
)
|
||||||
|
return sherpa_onnx.OfflineSpeakerDiarizationConfig(
|
||||||
|
segmentation=segmentation,
|
||||||
|
embedding=embedding,
|
||||||
|
clustering=clustering,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_segments(
|
||||||
|
segments: list[dict[str, Any]],
|
||||||
|
recording: Recording,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
durations: defaultdict[str, float] = defaultdict(float)
|
||||||
|
for segment in segments:
|
||||||
|
durations[str(segment["speaker"])] += segment["end"] - segment["start"]
|
||||||
|
|
||||||
|
ordered = sorted(durations.items(), key=lambda item: item[1], reverse=True)
|
||||||
|
total = sum(durations.values())
|
||||||
|
residual = sum(duration for _, duration in ordered[recording.expected_speakers :])
|
||||||
|
substantial_threshold = max(5.0, recording.duration * 0.02)
|
||||||
|
return {
|
||||||
|
"clusters": len(ordered),
|
||||||
|
"substantial_clusters": sum(
|
||||||
|
duration >= substantial_threshold for _, duration in ordered
|
||||||
|
),
|
||||||
|
"substantial_threshold_seconds": substantial_threshold,
|
||||||
|
"cluster_durations_seconds": dict(ordered),
|
||||||
|
"speaker_time_seconds": total,
|
||||||
|
"residual_seconds_after_expected": residual,
|
||||||
|
"residual_share_after_expected": residual / total if total else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def save_output(path: Path, output: dict[str, Any]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(output, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
temporary.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_shape(manifest: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Отделить параметры эксперимента от машинно-зависимых путей."""
|
||||||
|
return {
|
||||||
|
"models": [item["name"] for item in manifest["models"]],
|
||||||
|
"recordings": [
|
||||||
|
{
|
||||||
|
key: item[key]
|
||||||
|
for key in ("name", "start", "duration", "expected_speakers")
|
||||||
|
}
|
||||||
|
for item in manifest["recordings"]
|
||||||
|
],
|
||||||
|
"runs": manifest["runs"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = parse_args()
|
||||||
|
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
|
||||||
|
args.work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
recordings = [
|
||||||
|
Recording(
|
||||||
|
name=item["name"],
|
||||||
|
path=Path(item["path"]),
|
||||||
|
start=float(item["start"]),
|
||||||
|
duration=float(item["duration"]),
|
||||||
|
expected_speakers=int(item["expected_speakers"]),
|
||||||
|
reference=Path(item["reference"]) if item.get("reference") else None,
|
||||||
|
)
|
||||||
|
for item in manifest["recordings"]
|
||||||
|
]
|
||||||
|
if args.output.exists():
|
||||||
|
output = json.loads(args.output.read_text(encoding="utf-8"))
|
||||||
|
if (
|
||||||
|
manifest_shape(output["manifest"]) != manifest_shape(manifest)
|
||||||
|
or output["threads"] != args.threads
|
||||||
|
):
|
||||||
|
raise ValueError("Существующий output создан с другим manifest/threads")
|
||||||
|
output["manifest"] = manifest
|
||||||
|
else:
|
||||||
|
output = {
|
||||||
|
"manifest": manifest,
|
||||||
|
"sherpa_onnx_version": sherpa_onnx.__version__,
|
||||||
|
"threads": args.threads,
|
||||||
|
"results": [],
|
||||||
|
}
|
||||||
|
completed = {
|
||||||
|
(item["recording"], item["model"], item["run"]) for item in output["results"]
|
||||||
|
}
|
||||||
|
|
||||||
|
segmentation_model = Path(manifest["segmentation_model"])
|
||||||
|
for recording in recordings:
|
||||||
|
print(f"Декодирование {recording.name}", flush=True)
|
||||||
|
wav_path = decode_clip(recording, args.work_dir)
|
||||||
|
samples = read_wav(wav_path)
|
||||||
|
reference_turns = read_reference_turns(recording)
|
||||||
|
source_hash = file_sha256(recording.path)
|
||||||
|
|
||||||
|
for model in manifest["models"]:
|
||||||
|
embedding_model = Path(model["path"])
|
||||||
|
for run in manifest["runs"]:
|
||||||
|
run_key = (recording.name, model["name"], run["name"])
|
||||||
|
if run_key in completed:
|
||||||
|
print(f"Пропуск готового прогона: {run_key}", flush=True)
|
||||||
|
continue
|
||||||
|
num_clusters = run["num_clusters"]
|
||||||
|
if num_clusters == "expected":
|
||||||
|
num_clusters = recording.expected_speakers
|
||||||
|
threshold = float(run["threshold"])
|
||||||
|
print(
|
||||||
|
f"{recording.name}: {model['name']} / {run['name']}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
config = make_config(
|
||||||
|
segmentation_model=segmentation_model,
|
||||||
|
embedding_model=embedding_model,
|
||||||
|
threshold=threshold,
|
||||||
|
num_clusters=int(num_clusters),
|
||||||
|
threads=args.threads,
|
||||||
|
)
|
||||||
|
diarizer = sherpa_onnx.OfflineSpeakerDiarization(config)
|
||||||
|
started = time.perf_counter()
|
||||||
|
result = diarizer.process(samples)
|
||||||
|
elapsed = time.perf_counter() - started
|
||||||
|
segments = [
|
||||||
|
{
|
||||||
|
"speaker": int(segment.speaker),
|
||||||
|
"start": float(segment.start),
|
||||||
|
"end": float(segment.end),
|
||||||
|
}
|
||||||
|
for segment in result.sort_by_start_time()
|
||||||
|
]
|
||||||
|
item = {
|
||||||
|
"recording": recording.name,
|
||||||
|
"source": recording.path.name,
|
||||||
|
"source_sha256": source_hash,
|
||||||
|
"clip_start": recording.start,
|
||||||
|
"clip_duration": recording.duration,
|
||||||
|
"expected_speakers": recording.expected_speakers,
|
||||||
|
"reference": recording.reference.name
|
||||||
|
if recording.reference
|
||||||
|
else None,
|
||||||
|
"model": model["name"],
|
||||||
|
"model_file": embedding_model.name,
|
||||||
|
"run": run["name"],
|
||||||
|
"threshold": threshold,
|
||||||
|
"num_clusters": int(num_clusters),
|
||||||
|
"elapsed_seconds": elapsed,
|
||||||
|
"rtf": elapsed / recording.duration,
|
||||||
|
"summary": summarize_segments(segments, recording),
|
||||||
|
"reference_mapping": best_mapping(segments, reference_turns),
|
||||||
|
"segments": segments,
|
||||||
|
}
|
||||||
|
output["results"].append(item)
|
||||||
|
completed.add(run_key)
|
||||||
|
save_output(args.output, output)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user