chore(diarization): добавлена обвязка замеров в .scratch

- Зачем:
  - тикеты карты #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) <noreply@anthropic.com>
This commit is contained in:
Dmitriy Dementiev
2026-08-12 15:35:20 +03:00
co-authored by Claude Opus 5
parent 87030e9718
commit 1cb6a36c92
7 changed files with 550 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
# модели диаризации — 33 МБ, скачиваются по README
models/
# выход замеров
segments-*.tsv
conflict-*.json
+63
View File
@@ -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 и проверяет код возврата.
+49
View File
@@ -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)
+150
View File
@@ -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,
)
+87
View File
@@ -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,
)
+62
View File
@@ -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)
+133
View File
@@ -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