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
+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,
)