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