From 1cb6a36c9292b71280c8234521dc83f502b592f2 Mon Sep 17 00:00:00 2001 From: Dmitriy Dementiev Date: Wed, 12 Aug 2026 15:35:20 +0300 Subject: [PATCH] =?UTF-8?q?chore(diarization):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BE=D0=B1=D0=B2=D1=8F=D0=B7?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=80=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B2=20.scratch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - тикеты карты #10-#13 опираются на измерительную обвязку, которая до сих пор жила во временном каталоге сессии и исчезла бы вместе с ним. - Что: - перенесены четыре скрипта разведки: ASR, один прогон диаризации, свип порога кластеризации и подсчёт чистоты ASR-сегментов. - общая часть вынесена в common.py: пути от корня репозитория вместо захардкоженных, конфигурация диаризатора, проверка наличия моделей. - починен замер пиковой памяти: нужен экспорт K32GetProcessMemoryInfo из kernel32 и явные argtypes, иначе дескриптор процесса уезжает 32-битным. - модели и выход замеров исключены из истории локальным .gitignore. - Проверка: - export PYTHONIOENCODING=utf-8 - uv run --with sherpa-onnx python .scratch/diarization/bench_diar.py "<запись>" 8 0.9 Co-Authored-By: Claude Opus 5 (1M context) --- .scratch/diarization/.gitignore | 6 + .scratch/diarization/README.md | 63 +++++++++++ .scratch/diarization/bench_asr.py | 49 ++++++++ .scratch/diarization/bench_conflict.py | 150 +++++++++++++++++++++++++ .scratch/diarization/bench_diar.py | 87 ++++++++++++++ .scratch/diarization/bench_sweep.py | 62 ++++++++++ .scratch/diarization/common.py | 133 ++++++++++++++++++++++ 7 files changed, 550 insertions(+) create mode 100644 .scratch/diarization/.gitignore create mode 100644 .scratch/diarization/README.md create mode 100644 .scratch/diarization/bench_asr.py create mode 100644 .scratch/diarization/bench_conflict.py create mode 100644 .scratch/diarization/bench_diar.py create mode 100644 .scratch/diarization/bench_sweep.py create mode 100644 .scratch/diarization/common.py diff --git a/.scratch/diarization/.gitignore b/.scratch/diarization/.gitignore new file mode 100644 index 0000000..7dfbded --- /dev/null +++ b/.scratch/diarization/.gitignore @@ -0,0 +1,6 @@ +# модели диаризации — 33 МБ, скачиваются по README +models/ + +# выход замеров +segments-*.tsv +conflict-*.json diff --git a/.scratch/diarization/README.md b/.scratch/diarization/README.md new file mode 100644 index 0000000..b046210 --- /dev/null +++ b/.scratch/diarization/README.md @@ -0,0 +1,63 @@ +# Обвязка замеров диаризации + +Исследовательские скрипты для карты +[Карта: диаризация спикеров в транскрипте](https://git.dementev.space/ddmitry/local-transcriber/issues/8) (#8). +Не часть пакета: они опираются на `sherpa-onnx`, которого нет в зависимостях +проекта, и живут в `.scratch/`, а не в `src/`. + +Результаты первого прогона описаны в +[разведочном замере](../../docs/benchmarks/2026-08-12-diarization-feasibility.md). + +## Модели + +Скачиваются один раз в `models/`, в git не попадают (см. `.gitignore` рядом). + +```bash +mkdir -p models && cd models +curl -sSL -O https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-segmentation-models/sherpa-onnx-pyannote-segmentation-3-0.tar.bz2 +tar xjf sherpa-onnx-pyannote-segmentation-3-0.tar.bz2 +curl -sSL -O https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/wespeaker_en_voxceleb_resnet34_LM.onnx +``` + +Сегментация — 6,9 МБ, эмбеддинги — 26,5 МБ. Опечатка `recongition` в URL +относится к самому релизу sherpa-onnx, это не ошибка набора. + +## Скрипты + +| Скрипт | Что делает | Тикеты | +|---|---|---| +| `bench_asr.py` | ASR тем же путём, что CLI: время, RTF, память | #13 | +| `bench_diar.py` | один прогон диаризации, сохраняет разметку в `segments-<порог>.tsv` | #12, #13 | +| `bench_sweep.py` | свип порога кластеризации и явного числа говорящих | #10 | +| `bench_conflict.py` | доля ASR-сегментов, внутри которых меняется говорящий | #11 | +| `common.py` | пути, конфигурация диаризатора, замер памяти | — | + +## Запуск + +Из корня репозитория. `PYTHONIOENCODING=utf-8` нужен, иначе вывод падает на +консоли cp1251. + +```bash +export PYTHONIOENCODING=utf-8 + +uv run python .scratch/diarization/bench_asr.py "<путь к записи>" +uv run --with sherpa-onnx python .scratch/diarization/bench_diar.py "<путь>" 8 0.9 +uv run --with sherpa-onnx python .scratch/diarization/bench_sweep.py "<путь>" +uv run --with sherpa-onnx python .scratch/diarization/bench_conflict.py "<путь>" +``` + +## Что стоит знать до запуска + +- **Порог кластеризации не откалиброван.** По умолчанию стоит 0,9 — значение из + разведки, подобранное на одной записи и на ней же проверенное. На пороге 0,5 + из примеров sherpa-onnx получалось 29 говорящих вместо трёх. Калибровка — это + тикет #10, до его закрытия любое значение считается временным. +- **Свип дорогой.** Каждая конфигурация — полный прогон сегментации и + эмбеддингов, около 2,5 минут на 26-минутную запись, и время от настроек + кластеризации практически не зависит. Свип вести на коротком фрагменте. +- **Чистота сегментов меряется относительно диаризации.** Если её границы + систематически смещены, метрика измеряет не то, что кажется. Проверка границ + на слух — тикет #12, и он намеренно идёт до калибровки. +- **Замер памяти чинился.** В разведке `psapi.GetProcessMemoryInfo` молча + возвращал ноль; `common.peak_rss_mb()` теперь зовёт `K32GetProcessMemoryInfo` + из kernel32 и проверяет код возврата. diff --git a/.scratch/diarization/bench_asr.py b/.scratch/diarization/bench_asr.py new file mode 100644 index 0000000..2d5225c --- /dev/null +++ b/.scratch/diarization/bench_asr.py @@ -0,0 +1,49 @@ +"""Замер ASR тем же путём, что использует CLI — для соотношения с диаризацией. + + uv run python .scratch/diarization/bench_asr.py <файл> [модель] + +sherpa-onnx здесь не нужен: скрипт зовёт бэкенд проекта напрямую. +""" + +from __future__ import annotations + +import sys +import time +from pathlib import Path + +from common import peak_rss_mb, use_project_sources + +use_project_sources() + +from local_transcriber.backends.onnx_asr import OnnxAsrBackend # noqa: E402 + +DEFAULT_MODEL = "gigaam-v3-e2e-rnnt" +COMPUTE_TYPE = "int8" + + +def main(audio_path: str, model_name: str) -> None: + backend = OnnxAsrBackend(compute_type_explicit=False) + + t0 = time.perf_counter() + model_path = backend.ensure_model_available(model_name, COMPUTE_TYPE) + model = backend.create_model(model_path, "onnx", COMPUTE_TYPE) + t_load = time.perf_counter() - t0 + + t0 = time.perf_counter() + result = backend.transcribe(model, Path(audio_path), "ru") + t_asr = time.perf_counter() - t0 + rss = peak_rss_mb() + + print(f"файл: {audio_path}") + print(f"модель: {model_name} ({COMPUTE_TYPE})") + print(f"длительность: {result.duration / 60:.1f} мин") + print(f"загрузка модели: {t_load:.1f} с") + print(f"ASR: {t_asr:.1f} с -> {result.duration / t_asr:.1f}x RTF") + print(f"пиковая память процесса: {rss:.0f} МБ" if rss else "память: снять не удалось") + print(f"сегментов: {len(result.segments)}") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + raise SystemExit(__doc__) + main(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else DEFAULT_MODEL) diff --git a/.scratch/diarization/bench_conflict.py b/.scratch/diarization/bench_conflict.py new file mode 100644 index 0000000..46664ca --- /dev/null +++ b/.scratch/diarization/bench_conflict.py @@ -0,0 +1,150 @@ +"""Чистота ASR-сегментов: как часто внутри одного сегмента меняется говорящий. + + uv run --with sherpa-onnx python .scratch/diarization/bench_conflict.py <файл> + +Прогоняет ASR и диаризацию по одному файлу и считает, какая доля ASR-сегментов +содержит чужую речь. Это мера того, насколько огрубляет привязка спикера к +целому сегменту по мажоритарному перекрытию. +""" + +from __future__ import annotations + +import json +import sys +import time +from collections import defaultdict +from pathlib import Path + +from common import ( + DEFAULT_THREADS, + DISCOVERY_THRESHOLD, + HERE, + load_audio, + make_diarizer, + use_project_sources, +) + +use_project_sources() + +ASR_MODEL = "gigaam-v3-e2e-rnnt" +COMPUTE_TYPE = "int8" + +# чужая речь короче порога — поддакивание, дольше — потерянная реплика +INTERJECTION_S = 1.0 + +PURITY_LEVELS = (0.95, 0.90, 0.80, 0.70) + + +def run_asr(audio_path: str): + from local_transcriber.backends.onnx_asr import OnnxAsrBackend + + backend = OnnxAsrBackend(compute_type_explicit=False) + path = backend.ensure_model_available(ASR_MODEL, COMPUTE_TYPE) + model = backend.create_model(path, "onnx", COMPUTE_TYPE) + t0 = time.perf_counter() + result = backend.transcribe(model, Path(audio_path), "ru") + print(f"ASR: {time.perf_counter() - t0:.0f} с, {len(result.segments)} сегм.") + return result + + +def run_diar(samples, threshold: float, threads: int): + diarizer = make_diarizer(threshold=threshold, threads=threads) + t0 = time.perf_counter() + segments = diarizer.process(samples).sort_by_start_time() + print(f"диаризация: {time.perf_counter() - t0:.0f} с, {len(segments)} интервалов") + return [(s.start, s.end, s.speaker) for s in segments] + + +def main(audio_path: str, threshold: float, threads: int) -> None: + samples = load_audio(audio_path) + asr = run_asr(audio_path) + diar = run_diar(samples, threshold, threads) + print(f"речи по диаризации: {sum(e - s for s, e, _ in diar) / 60:.1f} мин\n") + + rows = [] + for seg in asr.segments: + per_speaker: dict[int, float] = defaultdict(float) + for start, end, speaker in diar: + overlap = min(seg.end, end) - max(seg.start, start) + if overlap > 0: + per_speaker[speaker] += overlap + total = sum(per_speaker.values()) + if total <= 0: + rows.append((seg, None, 0.0, 0.0, {})) + continue + major = max(per_speaker, key=lambda k: per_speaker[k]) + rows.append( + (seg, major, per_speaker[major] / total, total - per_speaker[major], dict(per_speaker)) + ) + + n = len(rows) + unattributed = [r for r in rows if r[1] is None] + attributed = [r for r in rows if r[1] is not None] + lost = [r for r in attributed if r[3] >= INTERJECTION_S] + interjection = [r for r in attributed if 0 < r[3] < INTERJECTION_S] + clean = [r for r in attributed if r[3] == 0] + + def minutes(rs) -> float: + return sum(r[0].end - r[0].start for r in rs) / 60 + + print("=" * 64) + print(f"ASR-сегментов: {n} ({minutes(rows):.1f} мин)\n") + for label, group in ( + ("чистых (один говорящий)", clean), + (f"с поддакиванием (<{INTERJECTION_S:.0f} с чужой)", interjection), + (f"с чужой репликой (>={INTERJECTION_S:.0f} с)", lost), + ("без говорящего вообще", unattributed), + ): + print(f" {label:<34} {len(group):4d} {len(group) / n * 100:5.1f}% {minutes(group):5.1f} мин") + + print() + for level in PURITY_LEVELS: + bad = [r for r in attributed if r[2] < level] + print( + f" чистота мажоритарного < {level:.2f}: {len(bad):4d} сегм. " + f"({len(bad) / n * 100:.1f}%), {minutes(bad):.1f} мин" + ) + + print("\n" + "=" * 64) + print("ХУДШИЕ 12 СЕГМЕНТОВ (больше всего чужой речи внутри):") + for seg, major, purity, others, per_speaker in sorted(attributed, key=lambda r: -r[3])[:12]: + share = ", ".join( + f"spk{k}={v:.1f}с" for k, v in sorted(per_speaker.items(), key=lambda x: -x[1]) + ) + print( + f"\n [{seg.start:7.1f}-{seg.end:7.1f}] ({seg.end - seg.start:4.1f} с) " + f"мажор spk{major}, чистота {purity:.2f}, чужой {others:.1f} с" + ) + print(f" {share}") + print(f" «{seg.text.strip()[:150]}»") + + out = HERE / f"conflict-{Path(audio_path).stem[:40]}.json" + out.write_text( + json.dumps( + { + "file": Path(audio_path).name, + "threshold": threshold, + "asr_segments": n, + "clean": len(clean), + "interjection": len(interjection), + "lost_utterance": len(lost), + "unattributed": len(unattributed), + "minutes_lost_utterance": round(minutes(lost), 2), + "minutes_total": round(minutes(rows), 2), + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + print(f"\nсводка сохранена: {out.name}") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + raise SystemExit(__doc__) + main( + sys.argv[1], + float(sys.argv[2]) if len(sys.argv) > 2 else DISCOVERY_THRESHOLD, + int(sys.argv[3]) if len(sys.argv) > 3 else DEFAULT_THREADS, + ) diff --git a/.scratch/diarization/bench_diar.py b/.scratch/diarization/bench_diar.py new file mode 100644 index 0000000..1ad3b94 --- /dev/null +++ b/.scratch/diarization/bench_diar.py @@ -0,0 +1,87 @@ +"""Один прогон диаризации: скорость, память, распределение по говорящим. + + uv run --with sherpa-onnx python .scratch/diarization/bench_diar.py <файл> [потоки] + +Сохраняет разметку в ``segments-<порог>.tsv`` рядом со скриптом — она нужна +тикету про проверку границ на слух и скрипту bench_conflict.py. +""" + +from __future__ import annotations + +import sys +import time + +import numpy as np + +from common import ( + DEFAULT_THREADS, + DISCOVERY_THRESHOLD, + HERE, + SAMPLE_RATE, + load_audio, + make_diarizer, + peak_rss_mb, +) + + +def main(audio_path: str, threads: int, threshold: float) -> None: + print(f"файл: {audio_path}") + print(f"потоков: {threads}, порог кластеризации: {threshold}") + + t0 = time.perf_counter() + samples = load_audio(audio_path) + t_decode = time.perf_counter() - t0 + duration = len(samples) / SAMPLE_RATE + print(f"длительность: {duration / 60:.1f} мин ({duration:.0f} с)") + print(f"декодирование: {t_decode:.1f} с ({duration / t_decode:.0f}x RTF)") + + t0 = time.perf_counter() + diarizer = make_diarizer(threshold=threshold, threads=threads) + print(f"инициализация моделей: {time.perf_counter() - t0:.1f} с") + + progress = {"shown": 0.0} + t_start = time.perf_counter() + + def on_progress(processed: int, total: int, _arg=None) -> int: + pct = processed / total * 100 + if pct - progress["shown"] >= 20: + progress["shown"] = pct + print(f" ... {pct:.0f}% ({time.perf_counter() - t_start:.0f} с)", flush=True) + return 0 + + segments = diarizer.process(samples, callback=on_progress).sort_by_start_time() + t_diar = time.perf_counter() - t_start + + speakers = sorted({s.speaker for s in segments}) + speech = sum(s.end - s.start for s in segments) + rss = peak_rss_mb() + + print() + print(f"ДИАРИЗАЦИЯ: {t_diar:.1f} с -> {duration / t_diar:.1f}x RTF") + print(f"пиковая память процесса: {rss:.0f} МБ" if rss else "память: снять не удалось") + print(f"спикеров: {len(speakers)}, интервалов: {len(segments)}") + print(f"речи: {speech / 60:.1f} мин ({speech / duration * 100:.0f}% файла)") + print() + print("распределение по говорящим:") + for spk in speakers: + own = [s for s in segments if s.speaker == spk] + total = sum(s.end - s.start for s in own) + median = np.median([s.end - s.start for s in own]) + print(f" spk{spk:<3} {total / 60:6.1f} мин {len(own):4d} интерв. медиана {median:.1f} с") + + out = HERE / f"segments-{threshold}.tsv" + out.write_text( + "\n".join(f"{s.start:.3f}\t{s.end:.3f}\t{s.speaker}" for s in segments), + encoding="utf-8", + ) + print(f"\nразметка сохранена: {out.name}") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + raise SystemExit(__doc__) + main( + sys.argv[1], + int(sys.argv[2]) if len(sys.argv) > 2 else DEFAULT_THREADS, + float(sys.argv[3]) if len(sys.argv) > 3 else DISCOVERY_THRESHOLD, + ) diff --git a/.scratch/diarization/bench_sweep.py b/.scratch/diarization/bench_sweep.py new file mode 100644 index 0000000..4943f07 --- /dev/null +++ b/.scratch/diarization/bench_sweep.py @@ -0,0 +1,62 @@ +"""Свип порога кластеризации и явного числа говорящих. + + uv run --with sherpa-onnx python .scratch/diarization/bench_sweep.py <файл> [потоки] + +Каждая конфигурация — полный прогон сегментации и эмбеддингов (около 2,5 минут +на 26-минутную запись), поэтому свип имеет смысл вести на коротком фрагменте, а +полные записи оставить для проверки финального кандидата. +""" + +from __future__ import annotations + +import sys +import time + +from common import DEFAULT_THREADS, SAMPLE_RATE, load_audio, make_diarizer + +# подпись, num_clusters, threshold +CONFIGS = [ + ("авто, порог 0.5", -1, 0.5), + ("авто, порог 0.7", -1, 0.7), + ("авто, порог 0.9", -1, 0.9), + ("явно k=5", 5, 0.5), +] + +# говорящий с речью короче порога считается остаточным кластером, не участником +MIN_SPEAKER_S = 30.0 + + +def main(audio_path: str, threads: int) -> None: + samples = load_audio(audio_path) + duration = len(samples) / SAMPLE_RATE + print(f"файл: {audio_path}") + print(f"длительность: {duration / 60:.1f} мин, потоков: {threads}\n") + + for label, num_clusters, threshold in CONFIGS: + diarizer = make_diarizer( + threshold=threshold, num_clusters=num_clusters, threads=threads + ) + t0 = time.perf_counter() + segments = diarizer.process(samples).sort_by_start_time() + elapsed = time.perf_counter() - t0 + + totals: dict[int, float] = {} + for seg in segments: + totals[seg.speaker] = totals.get(seg.speaker, 0.0) + (seg.end - seg.start) + real = [spk for spk, t in totals.items() if t >= MIN_SPEAKER_S] + top = sorted(totals.values(), reverse=True)[:8] + + print(f"--- {label}") + print( + f" {elapsed:.0f} с ({duration / elapsed:.1f}x RTF), " + f"говорящих: {len(totals)}, из них >= {MIN_SPEAKER_S:.0f} с речи: {len(real)}, " + f"интервалов: {len(segments)}" + ) + print(" топ по времени (мин): " + ", ".join(f"{t / 60:.1f}" for t in top)) + print() + + +if __name__ == "__main__": + if len(sys.argv) < 2: + raise SystemExit(__doc__) + main(sys.argv[1], int(sys.argv[2]) if len(sys.argv) > 2 else DEFAULT_THREADS) diff --git a/.scratch/diarization/common.py b/.scratch/diarization/common.py new file mode 100644 index 0000000..56d4a35 --- /dev/null +++ b/.scratch/diarization/common.py @@ -0,0 +1,133 @@ +"""Общая обвязка для замеров диаризации. + +Скрипты в этом каталоге — исследовательские, не часть пакета. Они опираются на +``sherpa-onnx``, которого нет в зависимостях проекта, поэтому запускаются через +``uv run --with sherpa-onnx``. +""" + +from __future__ import annotations + +import ctypes +import ctypes.wintypes as wt +import sys +from pathlib import Path +from typing import Any + +HERE = Path(__file__).resolve().parent +REPO_ROOT = HERE.parents[1] +MODELS = HERE / "models" + +SEGMENTATION = MODELS / "sherpa-onnx-pyannote-segmentation-3-0" / "model.onnx" +EMBEDDING = MODELS / "wespeaker_en_voxceleb_resnet34_LM.onnx" + +SAMPLE_RATE = 16_000 + +# Настройки разведки 2026-08-12. Порог 0.9 дал верное число говорящих на +# контрольной записи; на 0.5 из примеров sherpa-onnx получалось 29 вместо трёх. +DISCOVERY_THRESHOLD = 0.9 +DEFAULT_THREADS = 8 + + +def use_project_sources() -> None: + """Делает пакет проекта импортируемым без установки.""" + src = str(REPO_ROOT / "src") + if src not in sys.path: + sys.path.insert(0, src) + + +def require_models() -> None: + """Останавливает запуск с внятным сообщением, если модели не скачаны.""" + missing = [p for p in (SEGMENTATION, EMBEDDING) if not p.exists()] + if missing: + names = "\n ".join(str(p) for p in missing) + raise SystemExit( + f"Не найдены модели диаризации:\n {names}\n\n" + "Скачайте их по инструкции из README.md в этом каталоге." + ) + + +class _ProcessMemoryCounters(ctypes.Structure): + _fields_ = [ + ("cb", wt.DWORD), + ("PageFaultCount", wt.DWORD), + ("PeakWorkingSetSize", ctypes.c_size_t), + ("WorkingSetSize", ctypes.c_size_t), + ("QuotaPeakPagedPoolUsage", ctypes.c_size_t), + ("QuotaPagedPoolUsage", ctypes.c_size_t), + ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t), + ("QuotaNonPagedPoolUsage", ctypes.c_size_t), + ("PagefileUsage", ctypes.c_size_t), + ("PeakPagefileUsage", ctypes.c_size_t), + ] + + +def peak_rss_mb() -> float | None: + """Пиковая рабочая память процесса в МБ; None, если снять не удалось. + + Два подвоха, на которых замер в разведке 2026-08-12 вернул ноль: + экспорт на современных Windows живёт в kernel32 как + ``K32GetProcessMemoryInfo``, а без явных ``restype``/``argtypes`` + псевдодескриптор процесса уезжает в вызов как 32-битное число и функция + молча не срабатывает. + """ + kernel32 = ctypes.windll.kernel32 + kernel32.GetCurrentProcess.restype = ctypes.c_void_p + handle = kernel32.GetCurrentProcess() + + pmc = _ProcessMemoryCounters() + pmc.cb = ctypes.sizeof(_ProcessMemoryCounters) + + for dll, name in ( + (kernel32, "K32GetProcessMemoryInfo"), + (ctypes.windll.psapi, "GetProcessMemoryInfo"), + ): + func = getattr(dll, name, None) + if func is None: + continue + func.argtypes = [ + ctypes.c_void_p, + ctypes.POINTER(_ProcessMemoryCounters), + wt.DWORD, + ] + func.restype = wt.BOOL + if func(handle, ctypes.byref(pmc), pmc.cb): + return pmc.PeakWorkingSetSize / 1024 / 1024 + return None + + +def load_audio(audio_path: str | Path): + """Декодирует файл в моно 16 кГц — тот же путь, что использует ONNX-бэкенд.""" + from faster_whisper import decode_audio + + return decode_audio(str(audio_path), sampling_rate=SAMPLE_RATE) + + +def make_diarizer( + threshold: float = DISCOVERY_THRESHOLD, + num_clusters: int = -1, + threads: int = DEFAULT_THREADS, +) -> Any: + """Собирает OfflineSpeakerDiarization с параметрами разведки.""" + import sherpa_onnx as so + + require_models() + config = so.OfflineSpeakerDiarizationConfig( + segmentation=so.OfflineSpeakerSegmentationModelConfig( + pyannote=so.OfflineSpeakerSegmentationPyannoteModelConfig( + model=str(SEGMENTATION) + ), + num_threads=threads, + provider="cpu", + ), + embedding=so.SpeakerEmbeddingExtractorConfig( + model=str(EMBEDDING), num_threads=threads, provider="cpu" + ), + clustering=so.FastClusteringConfig( + num_clusters=num_clusters, threshold=threshold + ), + min_duration_on=0.3, + min_duration_off=0.5, + ) + diarizer = so.OfflineSpeakerDiarization(config) + assert diarizer.sample_rate == SAMPLE_RATE, diarizer.sample_rate + return diarizer