docs(diarization): собраны исследования и калибровка для карты #18

Merged
ddmitry merged 15 commits from feature/8-diarization-map into master 2026-08-14 17:19:15 +03:00
19 changed files with 2409 additions and 42 deletions
-1
View File
@@ -6,4 +6,3 @@ dist/
*.pyc
.codex
.qwen/
.scratch/
+6
View File
@@ -0,0 +1,6 @@
# модели диаризации — 33 МБ, скачиваются по README
models/
# выход замеров
segments-*.tsv
conflict-*.json
+65
View File
@@ -0,0 +1,65 @@
# Обвязка замеров диаризации
Исследовательские скрипты для карты
[Карта: диаризация спикеров в транскрипте](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.89
uv run --with sherpa-onnx python .scratch/diarization/bench_sweep.py "<путь>"
uv run --with sherpa-onnx python .scratch/diarization/bench_conflict.py "<путь>"
```
## Что стоит знать до запуска
- **Порог кластеризации откалиброван.** По умолчанию стоит 0,89 — единственное
проверенное значение, которое без знания числа участников дало правильные
3 / 2 / 2 кластера на трёх калибровочных фрагментах. Решение и ограничения
описаны в
[отчёте о калибровке](../../docs/benchmarks/2026-08-14-diarization-calibration.md).
- **Свип дорогой.** Каждая конфигурация — полный прогон сегментации и
эмбеддингов, около 2,5 минут на 26-минутную запись, и время от настроек
кластеризации практически не зависит. Свип вести на коротком фрагменте.
- **Чистота сегментов меряется относительно диаризации.** Если её границы
систематически смещены, метрика измеряет не то, что кажется. Проверка границ
на слух — тикет #12, и он намеренно идёт до калибровки.
- **Замер памяти чинился.** В разведке `psapi.GetProcessMemoryInfo` молча
возвращал ноль; `common.peak_rss_mb()` теперь зовёт `K32GetProcessMemoryInfo`
из kernel32 и проверяет код возврата. На Linux и macOS используется
`resource.getrusage()` с поправкой на разные единицы измерения.
+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)
+161
View File
@@ -0,0 +1,161 @@
"""Чистота 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)
+143
View File
@@ -0,0 +1,143 @@
"""Общая обвязка для замеров диаризации.
Скрипты в этом каталоге исследовательские, не часть пакета. Они опираются на
``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-14 на трёх записях.
# На 0.5 из примеров sherpa-onnx получалось 29 говорящих вместо трёх.
DISCOVERY_THRESHOLD = 0.89
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, если снять не удалось.
На Linux ``ru_maxrss`` измеряется в КиБ, на macOS в байтах. В Windows
используются системные счётчики процесса.
Два подвоха, на которых замер в разведке 2026-08-12 вернул ноль в Windows:
экспорт на современных Windows живёт в kernel32 как
``K32GetProcessMemoryInfo``, а без явных ``restype``/``argtypes``
псевдодескриптор процесса уезжает в вызов как 32-битное число и функция
молча не срабатывает.
"""
if sys.platform != "win32":
import resource
peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
divisor = 1024 * 1024 if sys.platform == "darwin" else 1024
return peak / divisor
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
+24
View File
@@ -15,3 +15,27 @@ _Avoid_: Доступная модель, дефолт
**Модель по умолчанию**:
Поддерживаемая модель, которую проект выбирает без явного указания модели пользователем для определённого пути выполнения.
_Avoid_: Рекомендуемая модель, поддерживаемая модель
**Опорная разметка**:
Разметка, принятая за точку отсчёта при измерении чего-то другого. Опорной её делает роль в измерении, а не качество: она не выверена вручную и сама может содержать ошибки, поэтому посчитанная по ней величина осмысленна как порядок, но не как точное значение.
_Avoid_: Эталонная разметка, истинная разметка, ground truth
**Сегмент распознавания**:
Непрерывный временной фрагмент аудио, для которого движок возвращает связный текст с общим контекстом. Может содержать речь нескольких говорящих и не равен реплике говорящего.
_Avoid_: Реплика, фраза говорящего
**Слово с временной привязкой**:
Распознанное слово, положение которого известно на временной шкале записи. Минимальная единица, которой назначается говорящий.
_Avoid_: Токен, ASR-сегмент
**Разметка говорящих**:
Упорядоченный набор временных интервалов речи, каждому из которых назначена анонимная метка говорящего. Не содержит распознанного текста, имени участника или голосового эмбеддинга.
_Avoid_: Результат диаризации, сегменты говорящих
**Голосовой кластер**:
Анонимная группа интервалов разметки говорящих, которые диаризатор относит к одному голосу. Не обязательно соответствует реальному участнику встречи: диаризация может создать ложный или малый кластер.
_Avoid_: Участник, человек
**Реплика говорящего**:
Последовательность соседних слов с временной привязкой, назначенных одному говорящему. Это единица структуры готового транскрипта, а не запуска модели распознавания.
_Avoid_: Сегмент распознавания, ASR-сегмент
@@ -0,0 +1,99 @@
# ADR-007: Пословная диаризация через sherpa-onnx
**Статус**: Принято
**Дата**: 2026-08-14
## Контекст
Разделение говорящих — главный структурный разрыв между локальным транскриптом
и облачными сервисами в сценарии подготовки конспектов и протоколов встреч.
Диаризация при этом не является ещё одним движком распознавания: она независимо
строит [разметку говорящих](../../CONTEXT.md#language), которую затем нужно
свести с результатом ASR.
Привязка одного говорящего ко всему сегменту распознавания оказалась слишком
грубой. На трёх русскоязычных рабочих созвонах чужая реплика не короче секунды
встретилась в 6–7% сегментов разговоров на двоих и в 27% сегментов встречи
втроём. Сегменты распознавания проходят по тишине, а не по смене говорящего,
поэтому сохранить контекст RNN-T и получить реплики можно только через более
мелкую единицу сведения.
## Эксперимент
Локальная связка `sherpa-onnx` с сегментацией Pyannote 3.0 и эмбеддингами
WeSpeaker ResNet34 LM проверена на трёх записях с известным составом. Для
автоматического определения числа голосовых кластеров выбран порог 0,89: это
единственное проверенное значение, которое на трёх контрольных фрагментах дало
3 / 2 / 2 кластера. На полной встрече втроём остался ложный кластер длительностью
19,1 секунды; поэтому малые кластеры нельзя молча отбрасывать, а разметку нельзя
считать эталоном точных границ и перекрывающейся речи.
Двуязычная CAMPPlus zh/en оказалась примерно на 30% быстрее и при известном
числе участников улучшила прокси-метрику на двух записях, но для неё не нашлось
общего автоматического порога без лишних кластеров или склейки реальных голосов.
Поэтому она остаётся кандидатом только для будущего режима с обязательным
явным числом участников, а не для первой версии.
На доступном слабом Intel baseline, Core i7-6820HQ с урезанным питанием,
последовательные ASR и диаризация обработали час записи примерно за 23 минуты.
Диаризация увеличивает полное время примерно в 2,4 раза, но остаётся быстрее
реального времени и приемлема как явно включаемая функция. Конкретный Core i5
11-го поколения не проверен, поскольку такого устройства нет.
Исходные данные и ограничения зафиксированы в отчётах о
[калибровке](../benchmarks/2026-08-14-diarization-calibration.md),
[смешении говорящих](../benchmarks/2026-08-14-asr-segment-speaker-mixing.md) и
[производительности на Intel](../benchmarks/2026-08-14-diarization-intel-i7.md).
## Решение
Диаризацию реализуем как явно включаемый пост-процессинг через `sherpa-onnx`.
Первая версия использует Pyannote segmentation 3.0, WeSpeaker ResNet34 LM,
порог кластеризации 0,89 и автоматическое число кластеров; известное число
участников можно передать явно.
Говорящий назначается [слову с временной
привязкой](../../CONTEXT.md#language), а не сегменту распознавания. Каждый
ASR-бэкенд приводит свой результат к общему набору слов с положением на
временной шкале. Проходы ASR и диаризации независимо получают одно аудио, после
чего отдельная операция сводит слова с интервалами разметки говорящих и
объединяет соседние слова одного говорящего в реплики. Распознавание по-прежнему
выполняется на полных сегментах и сохраняет контекст модели.
Диаризация не вводит грубый fallback на целый сегмент и не переключает
устройство ASR ради получения пословных таймкодов. Существующий GPU→CPU fallback
распознавания сохраняется и завершается до диаризации. Отсутствие пословных
таймкодов или ошибка инициализации диаризатора останавливают запуск до ASR.
Ошибка диаризации конкретного файла после успешного ASR не уничтожает полезный
результат: сохраняется обычный транскрипт с явным предупреждением и ненулевым
статусом, а батч продолжает остальные файлы. Порядок первой реализации, время
жизни диаризатора, кеш и подробная матрица поведения находятся в
[спецификации](../specs/2026-08-14-speaker-diarization.md).
## Последствия
- Общий контракт результата распознавания расширяется каноническими словами с
временной привязкой; сегменты распознавания сохраняются для совместимости и
контроля качества.
- FasterWhisper, ONNX-ASR и OpenVINO должны экспортировать один и тот же
пословный контракт. OpenVINO GenAI 2026.x уже предоставляет нужные таймкоды,
поэтому ограничение находится в адаптере проекта, а не в движке.
- `sherpa-onnx` становится обычной runtime-зависимостью, а две модели
диаризации скачиваются и кешируются лениво при первом запросе.
- Выход остаётся линейным Markdown с анонимными метками `Speaker N`.
Сопоставление голосов с именами и специальная запись перекрывающейся речи не
входят в ядро CLI.
- Последовательный режим задаёт корректный baseline. Параллельный запуск и
автоматическое включение на мощных устройствах требуют отдельных измерений
после стабилизации.
## Отклонённые альтернативы
| Альтернатива | Почему отклонена |
|---|---|
| Не делать диаризацию | Оставляет главный продуктовый разрыв, хотя измеренная стоимость допустима для явной функции |
| Мажоритарный говорящий на весь сегмент распознавания | Теряет чужие реплики на всех трёх проверенных записях |
| Сначала диаризация, затем ASR коротких интервалов | Лишает RNN-T длинного контекста и ухудшает согласование и пунктуацию |
| `pyannote.audio` | Тянет PyTorch и требует Hugging Face token с принятием лицензии |
| Сборка поверх приватных деталей `onnx-asr` | Экономит небольшую отдельную зависимость ценой нестабильного внутреннего API и собственной кластеризации |
| Параллельные проходы в первой версии | Нет прямого benchmark и измеренного общего пика памяти; сначала нужен корректный последовательный baseline |
+52
View File
@@ -64,3 +64,55 @@ tea labels list --remote origin
За пределами рабочего дерева явно указывать репозиторий
`ddmitry/local-transcriber` и настроенный Gitea login.
## Wayfinding operations
Навык `wayfinder` ведёт карту как issue с меткой `wayfinder:map`, а её тикеты —
как отдельные issue с метками `wayfinder:research`, `wayfinder:prototype`,
`wayfinder:grilling` и `wayfinder:task`.
### Принадлежность карте
Gitea 1.27 не имеет подзадач в API: среди эндпоинтов `issues/{index}` есть
`dependencies` и `blocks`, но родительских связей нет. Поэтому принадлежность
тикета карте выражается двумя способами сразу: меткой `wayfinder:<тип>` и первой
строкой тела со ссылкой на карту.
```markdown
Часть карты: [<заголовок карты>](<url>) (#<номер>)
```
### Блокировки
Блокировки — нативные зависимости Gitea, они отображаются в интерфейсе. Тикет
разблокирован, когда закрыты все блокирующие его тикеты.
```powershell
tea api --remote origin -X POST `
repos/ddmitry/local-transcriber/issues/<блокируемый>/dependencies `
-d '{"index": <блокирующий>, "owner": "ddmitry", "repo": "local-transcriber"}'
```
### Запросы фронтира
Фронтир — открытые, разблокированные и никому не назначенные тикеты карты.
Заявка на тикет — назначение его на себя до начала работы.
```powershell
tea issues list --remote origin --labels wayfinder:map
tea issues list --remote origin --labels wayfinder:research,wayfinder:prototype,wayfinder:grilling,wayfinder:task
tea api --remote origin repos/ddmitry/local-transcriber/issues/<номер>/dependencies
```
### Особенности `tea api`
Три вещи, на которых легко потерять время:
- **Путь без ведущего слэша.** `repos/{owner}/{repo}/...` работает,
`/repos/...` возвращает `404 page not found`. Подстановка `{owner}` и `{repo}`
из контекста репозитория при этом не срабатывает — писать владельца и имя явно.
- **Тело зависимости требует `owner` и `repo`.** Только `{"index": N}` даёт
`repository does not exist [id: 0, uid: 0, owner_name: , name: ]`.
- **Код возврата не отражает HTTP-статус.** `tea api` завершается с нулевым
кодом даже на 404, поэтому скрипты должны запрашивать `-i` и разбирать строку
`HTTP/...` из stderr, иначе ошибки пройдут незамеченными.
-40
View File
@@ -179,46 +179,6 @@ openvino-cpu, запись 25:59) с облачным сервисом Hypescrib
LLM для чистки текста, сопоставление Speaker N с именами — это работа
поверх готового транскрипта.
### Диаризация — разделение говорящих
**Что:** Опциональный пост-процессинг (не четвёртый бэкенд): сегменты
уже несут таймкоды; диаризация даёт интервалы «кто когда говорил»;
merge по перекрытию интервалов; formatter ломает абзац на смене спикера
и подписывает `Speaker 1:`. Ставится как extra:
`uv sync --extra diarization`.
**Почему:** Без спикеров MoM не собрать — это ключевой разрыв с облаком
по внешнему ревью, и никакое качество распознавания его не компенсирует.
Заодно естественно решает «разбивку на реплики» (приоритет №4).
**Варианты реализации (ключевое решение, нужен ADR):**
- **sherpa-onnx** — диаризация целиком на onnxruntime (сегментация
pyannote в ONNX + спикер-эмбеддинги), без torch, в духе нашего
onnx-стека и «no cloud, no API keys».
- **pyannote.audio** — стандарт качества, но тянет torch и требует
HF-токен с принятием лицензии моделей — трение с духом проекта.
**Уточнить перед запуском:** качество обоих вариантов на русской речи
и перекрывающихся репликах; скорость на CPU (диаризация — второй проход
по всему аудио); лицензии моделей сегментации/эмбеддингов.
---
### Ручка нарезки абзацев в formatter
**Что:** «Минутные простыни» в транскрипте — не свойство модели, а наши
константы группировки `_PAUSE_THRESHOLD_S = 2.0` / `_MAX_PARAGRAPH_S =
60.0` в `formatter.py` (сырых сегментов много: 23-минутная запись — 360
сегментов, ~4 с на реплику). Вынести в опцию/конфиг или уменьшить
дефолт.
**Почему откладывается:** при диаризации абзацы будут ломаться по смене
спикера естественно — сначала решить с диаризацией, чтобы не делать
ручку, которая устареет.
---
### Словарь замен технических терминов — запасной план
**Что:** Пост-обработка текста сегментов словарём замен по границам слов
@@ -0,0 +1,199 @@
# Разведочный замер диаризации sherpa-onnx
**Дата:** 2026-08-12
**Статус:** разведка на одной записи и одной нецелевой машине. Не приёмка.
## Цель
Ответить на два вопроса перед проектированием диаризации:
1. Сколько времени диаризация добавляет к транскрипции.
2. Достаточно ли приписывать спикера целому ASR-сегменту по мажоритарному
перекрытию — то есть допустима ли схема из
[бэклога](../backlog.md#диаризация--разделение-говорящих) без пословной
привязки.
Ни модель по умолчанию, ни код проекта в рамках замера не менялись. Все скрипты
выполнялись вне репозитория.
## Ограничения замера
Результаты ниже — разведка, а не основание для решения:
- **одна запись** вместо трёх, принятых в [ADR-006](../adr/006-onnx-asr-backend.md);
- **нецелевая машина**: AMD Ryzen 7 8845H, тогда как приёмка производительности
по бэклогу требует Intel Core i5 11-го поколения;
- **границы диаризации не проверены на слух** — сверялось только число
говорящих и косвенный текстовый признак;
- **порог кластеризации подобран по этой же записи**, то есть на ней же и
проверен.
## Оборудование и условия
- ноутбук Lenovo 83D5 (та же машина, что в
[сравнении turbo](2026-08-12-openvino-large-v3-turbo-comparison.md#повторный-прогон-на-amd-ryzen-7-8845h));
- AMD Ryzen 7 8845H, 8 ядер / 16 логических процессоров;
- 29,8 ГиБ LPDDR5X;
- Windows 11 Корпоративная, сборка 26200, схема питания «Сбалансированная»;
- Python 3.13.13, `onnx-asr` 0.12.0, `onnxruntime` 1.28.0,
`faster-whisper` 1.2.1, `sherpa-onnx` 1.13.5;
- прогоны последовательные, без конкурирующей нагрузки;
- модели предварительно скачаны; время загрузки моделей в замер не входит.
## Контрольная запись
| Файл | Длительность | Размер | SHA-256 |
|---|---|---|---|
| `2026-07-10 Data Test внутренний статус.mp4` | 26:00 | 34 217 769 | `1057616B42E8ADD00E0EB975B02BDEF0EC9F6CDFEC6DBF488E0C60423C9B7B87` |
Запись выбрана потому, что рядом лежит согласованный MoM, из которого известен
состав: **три участника** — Маша, Дима, Роман. Это даёт независимую опорную
точку для проверки числа говорящих.
## Модели диаризации
| Роль | Модель | Размер | Источник |
|---|---|---|---|
| Сегментация | `sherpa-onnx-pyannote-segmentation-3-0` | 6,9 МБ | релизы `k2-fsa/sherpa-onnx` |
| Эмбеддинги | `wespeaker_en_voxceleb_resnet34_LM.onnx` | 26,5 МБ | релизы `k2-fsa/sherpa-onnx` |
Обе загружаются в `onnxruntime`, torch и токен Hugging Face не требуются.
Эмбеддинги обучены на англоязычном VoxCeleb; их пригодность для русской речи в
этом замере не проверялась.
## Стоимость по времени
Параметры ASR — модель по умолчанию ONNX-пути, `gigaam-v3-e2e-rnnt` INT8,
язык задан явно.
| Стадия | Время | RTFx |
|---|---:|---:|
| Декодирование аудио | 1,6 с | ~990× |
| ASR `gigaam-v3-e2e-rnnt` INT8 | 95,3 с | 16,4× |
| Диаризация, 8 потоков | 140,7 с | 11,1× |
| **Последовательно, итого** | **236 с** | **6,6×** |
Диаризация дороже самой транскрипции и занимает около 60% общего времени. При
последовательном исполнении 26-минутная запись обрабатывается 3,9 минуты вместо
1,6; часовая — примерно 9 минут вместо 3,7.
Масштабирование по потокам слабое: 4 потока дают 154 с, 8 потоков — 141 с,
выигрыш 9%. Закладываться на увеличение числа потоков не следует.
Проходы ASR и диаризации независимы по данным, поэтому их можно совместить во
времени; тогда общее время стремится к максимуму из двух, а не к сумме. Прямой
замер параллельного режима не проводился.
Пиковую память процесса снять не удалось из-за ошибки в измерительном скрипте.
## Порог кластеризации
Число говорящих подбиралось автоматически (`num_clusters=-1`); варьировался
порог `FastClusteringConfig.threshold`.
| Конфигурация | Найдено спикеров | Из них с речью ≥30 с | Речь по спикерам, мин |
|---|---:|---:|---|
| порог 0,5 | 29 | 11 | 7,4 / 5,5 / 1,6 / 1,2 |
| порог 0,7 | 12 | 7 | 7,4 / 7,0 / 2,2 / 2,2 |
| **порог 0,9** | **4** | **3** | **9,6 / 8,1 / 5,7 / 0,3** |
| явное `k=5`, порог 0,5 | 4 | 3 | 9,6 / 8,1 / 5,7 / 0,3 |
На пороге 0,9 число содержательных кластеров совпало с составом из MoM: три
говорящих с 9,6, 8,1 и 5,7 минуты речи плюс остаточный кластер на 0,3 минуты.
На пороге 0,5 получилось 29 говорящих вместо трёх — десятикратное
переразбиение.
Время от порога не зависит (153–157 с во всех конфигурациях): кластеризация
стоит доли секунды, платится за сегментацию и эмбеддинги.
Два следствия. Первое: порог кластеризации — основная ручка качества, и
значение по умолчанию из примеров `sherpa-onnx` для этого материала непригодно.
Второе: одного удачного совпадения на одной записи недостаточно, чтобы принять
0,9 за дефолт, а явное указание числа участников нужно как страховка.
## Чистота ASR-сегментов
Основной вопрос замера. Для каждого из 274 ASR-сегментов посчитано перекрытие
с интервалами каждого говорящего (диаризация на пороге 0,9, 340 интервалов).
Чистота — доля мажоритарного говорящего в суммарном перекрытии сегмента.
| Категория | Сегментов | Доля | Времени |
|---|---:|---:|---:|
| Чистых, один говорящий | 164 | 59,9% | 8,3 мин |
| С поддакиванием, <1 с чужой речи | 30 | 10,9% | 2,4 мин |
| **С чужой репликой, ≥1 с** | **74** | **27,0%** | **11,2 мин** |
| Без спикера вообще | 6 | 2,2% | 0,0 мин |
| Порог чистоты | Сегментов ниже порога | Доля | Времени |
|---|---:|---:|---:|
| < 0,95 | 100 | 36,5% | 13,0 мин |
| < 0,90 | 91 | 33,2% | 11,6 мин |
| < 0,80 | 72 | 26,3% | 9,1 мин |
| < 0,70 | 56 | 20,4% | 7,1 мин |
**27% сегментов содержат не менее секунды чужой речи, и на них приходится
11,2 минуты из 21,9 — больше половины транскрипта.** У 20% сегментов
мажоритарный говорящий занимает менее двух третей сегмента.
### Подтверждение из текста ASR
Загрязнённые сегменты содержат диалог, и это видно независимо от диаризации —
`gigaam-v3-e2e` обучена с диалоговой пунктуацией и сама ставит тире на смене
реплики:
```
[533,8-551,1] 17,3 с, мажоритарный spk3, чистота 0,67
«— В нашем, по-моему.— В нашем?— В нашем.— А, отлично.— Ну, у нас просто
есть некий дата-тест, который на самом деле...— Мы же у них не
разворачиваем.—»
[432,9-448,6] 15,7 с, мажоритарный spk2, чистота 0,58
«Дим, а вот то, что ты из образа вытаскивал, там есть чё-то на что
посмотреть?— Бэг, бэг там есть, пи»
[694,2-706,5] 12,3 с, мажоритарный spk2, чистота 0,37
spk2 = 5,4 с, spk1 = 4,9 с, spk3 = 4,3 с — три человека в одном сегменте
```
Акустическая диаризация и пунктуация ASR указывают на одно и то же, поэтому
доля 27% вряд ли объясняется только ошибками кластеризации.
Причина загрязнения — в способе нарезки: границы сегментов на ONNX-пути даёт
Silero VAD, то есть они проходят по тишине. В разговоре по ВКС участники
отвечают встык, паузы длиной с порог VAD не возникает, и диалог попадает в один
сегмент. Предположение о том, что задержка канала связи сама создаёт паузу на
смене говорящего, этими данными не подтверждается.
Приведённые сегменты — сырой выход ASR. `formatter.py` объединяет их в абзацы
длиной до 60 секунд, поэтому в готовом транскрипте загрязнение будет выше.
## Выводы
- Диаризация через `sherpa-onnx` работает на целевой платформе без torch и без
токена Hugging Face; связка сегментация + эмбеддинги весит около 33 МБ.
- Стоимость — 11,1× RTFx, примерно 1,5 длительности ASR. Последовательный
запуск даёт 2,5-кратное замедление, совмещение проходов может сократить
накладные расходы, но отдельно не измерялось.
- Автоматическая оценка числа говорящих с порогом из примеров даёт 29 спикеров
вместо трёх. Порог требует калибровки, а явное указание числа участников —
отдельной ручки.
- Привязка спикера к целому ASR-сегменту по мажоритарному перекрытию
огрубляет результат существенно: 27% сегментов и больше половины времени
транскрипта содержат чужую речь длиннее секунды. Схема из бэклога в этом виде
непригодна.
- `onnx-asr` отдаёт потокенные таймкоды (`TimestampedResult`), поэтому
пословная привязка на ONNX-пути достижима. У OpenVINO GenAI такого выхода
нет, что ограничивает диаризацию на `--device openvino-*`.
## Что нужно проверить дальше
- Повторить измерение чистоты сегментов ещё на двух-трёх записях, включая
разговор на двоих, и убедиться, что 27% — не свойство именно этой встречи.
- Проверить границы диаризации на слух или сверкой с внешним транскриптом:
совпадение числа говорящих не доказывает правильность интервалов.
- Сравнить эмбеддинги, обученные не только на английском, на русской речи.
- Измерить производительность на доступном слабом Intel baseline — выполнено в
[отдельном отчёте](2026-08-14-diarization-intel-i7.md); конкретный Core i5
11-го поколения недоступен.
- Измерить параллельный режим ASR и диаризации.
@@ -0,0 +1,102 @@
# Смешение говорящих внутри ASR-сегментов
**Дата:** 2026-08-14
**Статус:** проверка на трёх русскоязычных рабочих созвонах. Не оценка DER и
не решение о единице привязки спикера к тексту.
## Вопрос
Воспроизводится ли смешение говорящих внутри ASR-сегментов на других записях,
или результат разведки — особенность одной встречи на троих?
В [разведочном замере](2026-08-12-diarization-feasibility.md) 27% сегментов
контрольной записи содержали не меньше секунды чужой речи. На них приходилось
больше половины времени ASR-сегментов. Проверка повторена на тех же трёх
записях, на которых калибровалась диаризация, включая два разговора на двоих.
## Метод
ASR выполнялся через модель по умолчанию ONNX-пути
`gigaam-v3-e2e-rnnt` INT8 с языком `ru`. Диаризация выполнялась через
`sherpa-onnx` 1.13.5 с выбранной в
[калибровке](2026-08-14-diarization-calibration.md) конфигурацией:
- сегментация `sherpa-onnx-pyannote-segmentation-3-0`;
- эмбеддинги `wespeaker_en_voxceleb_resnet34_LM.onnx`;
- `FastClusteringConfig.threshold=0.89`;
- автоматическое определение числа кластеров (`num_clusters=-1`).
Для каждого ASR-сегмента считалось перекрытие с интервалами каждого кластера.
Кластер с максимальным перекрытием считался мажоритарным, а сумма перекрытий
остальных кластеров — чужой речью. Сегменты разделены на четыре категории:
- **чистый** — перекрытие только с одним кластером;
- **с поддакиванием** — меньше 1 секунды чужой речи;
- **с чужой репликой** — не меньше 1 секунды чужой речи;
- **без спикера** — нет перекрытия с интервалами диаризации.
Время категории — сумма длительностей попавших в неё ASR-сегментов. Это не
сумма времени речи: интервалы разных кластеров могут перекрываться при
наложенной речи. Метрика отвечает на узкий вопрос, насколько огрубляет текст
одна метка спикера на весь ASR-сегмент. Она не измеряет точность диаризации.
## Результаты
| Запись | Участников | ASR-сегментов | Чистые | Поддакивание <1 с | Чужая реплика ≥1 с | Без спикера |
|---|---:|---:|---:|---:|---:|---:|
| Data Test | 3 | 274 | 164 (59,9%) | 30 (10,9%) | **74 (27,0%)** | 6 (2,2%) |
| T2 BDMA | 2 | 223 | 167 (74,9%) | 34 (15,2%) | **14 (6,3%)** | 8 (3,6%) |
| Yantar | 2 | 339 | 275 (81,1%) | 20 (5,9%) | **24 (7,1%)** | 20 (5,9%) |
| Запись | Время всех ASR-сегментов | Время сегментов с чужой репликой ≥1 с | Доля времени |
|---|---:|---:|---:|
| Data Test | 21,89 мин | **11,20 мин** | **51,2%** |
| T2 BDMA | 12,56 мин | **1,53 мин** | **12,2%** |
| Yantar | 15,93 мин | **2,67 мин** | **16,8%** |
На двух разговорах на двоих вместе чужая реплика не меньше секунды встречается
в 38 из 562 сегментов (6,8%) и затрагивает 4,20 из 28,49 минуты (14,7%). По
сравнению со встречей на троих это в четыре раза меньше по доле сегментов и
примерно в 3,5 раза меньше по доле времени.
## Вывод
Смешение говорящих внутри ASR-сегмента — общее свойство проверенного материала,
а не аномалия одной записи: оно воспроизвелось на обоих разговорах на двоих.
Однако тяжесть сильно зависит от характера разговора. Значение 27% сегментов и
51% времени не переносится на двухсторонние созвоны: там получено 6–7%
сегментов и 12–17% времени.
Мажоритарная метка на весь ASR-сегмент поэтому остаётся заметным огрублением
даже на разговорах на двоих, а на плотной встрече втроём теряет реплики в
массовом масштабе. Эти данные не выбирают единицу привязки сами по себе, но
исключают предположение, что сегментная привязка безопасна для всех обычных
созвонов без пословной привязки или явной пометки качества.
## Ограничения
- Все три записи — русскоязычные рабочие созвоны одного пользователя; другие
микрофоны, шумы и стили разговора не представлены.
- Диаризация служит опорной разметкой и сама содержит ошибки. На контрольной
записи есть ложный остаточный кластер, редкие пропуски и неполная разметка
перекрывающейся речи.
- На двухсторонних записях один голос заметно доминирует по времени. Баланс
реплик может влиять на долю смешанных сегментов.
- Порог 1 секунда разделяет короткие вставки и потенциально потерянные реплики,
но не доказывает смысловую важность каждого фрагмента.
## Воспроизводимость
Расчёт выполняется скриптом
[`bench_conflict.py`](../../.scratch/diarization/bench_conflict.py):
```powershell
$env:PYTHONIOENCODING = "utf-8"
uv run --with sherpa-onnx python .scratch/diarization/bench_conflict.py `
"<путь к записи>" 0.89 8
```
Медиа и сырые JSON не добавлены в репозиторий: они содержат локальные пути и
относятся к рабочим созвонам. SHA-256 всех трёх исходных файлов зафиксированы в
[отчёте о калибровке](2026-08-14-diarization-calibration.md).
@@ -0,0 +1,210 @@
# Калибровка модели эмбеддингов и порога диаризации
**Дата:** 2026-08-14
**Статус:** выбор конфигурации для проектирования. Производительность отдельно
проверена на [доступном старом Intel baseline](2026-08-14-diarization-intel-i7.md).
## Решение
Для автоматического определения числа участников использовать:
- эмбеддинги `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:0012:00 | 25:59,9 | `1057616B42E8ADD00E0EB975B02BDEF0EC9F6CDFEC6DBF488E0C60423C9B7B87` |
| `2026-07-29 T2 BDMA уточнение задачи от Ильи.mp4` | 2 | 00:0005:00 | 14:50,9 | `51866D247FE3EDA134CDD884F707B1F1DB8855B492E8BD14D2B56B62476255ED` |
| `2026-08-12 Созвон с Максом Мерлином по T2 Forecast и Yantar.mp4` | 2 | 00:0005: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 в этом эксперименте. Это плюс для
варианта с известным числом участников. Производительность выбранной WeSpeaker
отдельно проверена на доступном старом Intel Core i7.
## Воспроизводимость
Свип выполняется скриптом
[`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 i7; конкретный Core
i5 11-го поколения остаётся непроверенным, потому что такого устройства нет.
Для спецификации зафиксировать WeSpeaker + 0,89 как автоматический дефолт,
отдельную опцию явного числа участников и отсутствие автоматического
отбрасывания малых кластеров. CAMPPlus zh/en можно оставить кандидатом для
будущего режима с обязательным `num_clusters` после расширенной слуховой
проверки.
@@ -0,0 +1,96 @@
# Производительность диаризации на старом Intel Core i7
**Дата:** 2026-08-14
**Статус:** приёмка на доступном слабом Intel baseline. Не эквивалент замеру на
Intel Core i5 11-го поколения.
## Решение
Диаризация проходит по стоимости как **опциональная функция**. На доступном
ноутбуке обработка остаётся заметно быстрее реального времени: час записи
занимает около 23 минут при последовательном запуске ASR и диаризации.
Цена функции существенная: диаризация медленнее ASR и увеличивает полное время
примерно в 2,4 раза. Поэтому включать её без явного запроса пользователя нельзя.
Теоретическое совмещение независимых проходов уменьшило бы время часа записи до
примерно 13,6 минуты, но параллельный режим здесь не измерялся.
Запланированный Intel Core i5 11-го поколения недоступен и в обозримом будущем
не появится. Вместо бессрочного блокирующего требования принят доступный старый
Intel Core i7 как практический слабый baseline. Результат не переносится на
конкретный SKU i5 и не является сравнением микроархитектур.
## Оборудование и условия
- HP ZBook 17 G3, BIOS N81 01.61;
- Intel Core i7-6820HQ, 4 ядра / 8 логических процессоров, 2,7–3,6 ГГц;
- 29 ГиБ доступной RAM;
- Ubuntu, Linux 7.0.0-29-generic x86_64;
- питание от сети, профиль `balanced`, governor `powersave`; доступная мощность
была урезана, и ноутбук ограничивал производительность;
- Python 3.13.13, `onnx-asr` 0.12.0, `onnxruntime` 1.28.0,
`faster-whisper` 1.2.1, `sherpa-onnx` 1.13.5, NumPy 2.4.3;
- 8 потоков CPU, порог кластеризации 0,89;
- прогоны последовательные, без намеренно запущенной конкурирующей нагрузки;
- модели после первого запуска находились в локальном кеше.
По наблюдению владельца, ограничение питания снижало производительность примерно
на 30%. Это визуальная оценка, а не результат отдельного A/B-замера, поэтому
фактические времена ниже не пересчитываются. Их следует читать как консервативный
результат именно в зафиксированном режиме питания.
Использованы те же три записи и те же SHA-256, что в
[отчёте о калибровке](2026-08-14-diarization-calibration.md). Модель ASR —
`gigaam-v3-e2e-rnnt` INT8; диаризация — Pyannote segmentation 3.0 и WeSpeaker
ResNet34 LM.
## Результаты
Для самой длинной записи сделано три прогона каждого прохода. Для двух
остальных — по одному подтверждающему прогону: разброс трёх повторов был мал,
а коэффициенты на записях другой длины подтвердили линейное масштабирование.
| Запись | Длительность | ASR | RTFx ASR | Диаризация | RTFx диаризации |
|---|---:|---:|---:|---:|---:|
| Data Test | 26:00 | **257,1 с** (медиана: 261,2 / 257,1 / 253,7) | 6,1× | **352,6 с** (медиана: 352,6 / 349,8 / 358,8) | 4,4× |
| T2 BDMA | 14:51 | 149,0 с | 6,0× | 203,7 с | 4,4× |
| Yantar | 20:22 | 185,3 с | 6,6× | 276,0 с | 4,4× |
| **Взвешенно, три записи** | **61:13** | **591,4 с** | **6,2×** | **832,3 с** | **4,4×** |
Последовательная обработка трёх записей занимает около 1424 секунд, или
23,7 минуты, при общей длительности 61,2 минуты: **2,58× realtime**. В пересчёте
на час это около 9,7 минуты ASR и 13,6 минуты диаризации, всего **23,3 минуты**.
## Инициализация и память
| Проход | Инициализация из локального кеша | Peak RSS |
|---|---:|---:|
| ASR | 2,02,3 с | 9001035 МБ на тёплых прогонах |
| Диаризация | 0,2 с | 366469 МБ |
Первый ASR-запуск показал 23,6 секунды, но включал скачивание файлов модели,
поэтому не считается чистым cold start. Peak RSS этого процесса достиг 1174 МБ.
Пиковая память замерена отдельно для каждого последовательного прохода; для
будущего параллельного режима значения нельзя механически считать измеренным
общим пиком.
## Сопоставление с разведкой на Ryzen
На Ryzen 7 8845H для Data Test были получены 16,4× RTFx у ASR и 11,1× у
диаризации. На старом Intel оба прохода медленнее примерно в 2,6 раза, а их
соотношение почти не изменилось. Следовательно, слабое железо ухудшает абсолютное
время, но не меняет основной архитектурный вывод: диаризация дороже ASR, а
совмещение проходов потенциально полезно.
## Ограничения
- Core i7-6820HQ не моделирует производительность Core i5 11-го поколения;
- влияние урезанного питания оценивается примерно в 30% только на глаз; прогон с
полным питанием для сравнения не проводился;
- медиана трёх прогонов снята только на одной полной записи, на двух других есть
по одному подтверждающему прогону;
- чистый cold start ASR без скачивания, но с холодным файловым кешем не измерен;
- параллельный запуск ASR и диаризации не измерен;
- результат отвечает только на стоимость выбранных моделей и конфигурации, а не
на качество диаризации.
@@ -0,0 +1,491 @@
# Куда движутся ONNX Runtime и OpenVINO: жизненный цикл и переносимость моделей
**Дата:** 2026-08-12
**Статус:** исследование для карты диаризации. Не архитектурное решение и не
основание для консолидации всех движков распознавания на ONNX Runtime.
## Вопрос и границы
Исследование отвечает на два связанных вопроса:
1. Насколько устойчивы ONNX Runtime (ORT), его аппаратные Execution Provider
(EP), Windows ML и нативный стек OpenVINO/OpenVINO GenAI?
2. Что эти пути практически дают текущим моделям проекта — GigaAM E2E RNN-T,
OpenVINO Whisper и связке диаризации PyAnnote + WeSpeaker — на Intel, AMD,
Apple Silicon и в браузере?
Терминология следует [`CONTEXT.md`](../../CONTEXT.md): ORT, OpenVINO и OpenVINO
GenAI — **движки распознавания**. Обновление движка само по себе не меняет
поддерживаемую модель или модель по умолчанию. Архитектурная точка отсчёта —
независимые бэкенды из [ADR-003](../adr/003-pluggable-backends.md) и принятый
ONNX CPU-путь из [ADR-006](../adr/006-onnx-asr-backend.md).
Исследование дополняет [срез обновлений движков](2026-08-10-engine-model-updates.md)
и [разведку диаризации](../benchmarks/2026-08-12-diarization-feasibility.md).
Оно основано на первичных источниках: официальной документации, release notes,
репозиториях владельцев и фактических метаданных PyPI на 2026-08-12.
Вне границ документа:
- решение о консолидации проекта на одном runtime;
- выбор нового устройства или модели по умолчанию;
- обещание производительности без model-specific benchmark;
- разработка браузерной версии `local-transcriber`.
## Краткий ответ
- **Переносимый фундамент проекта — ONNX-артефакт плюс ORT CPU EP.** ORT core
активно развивается и имеет наиболее широкую поставку для CPython 3.13:
Windows и Linux x86-64/ARM64, macOS ARM64.
- **Жизненный цикл ядра ORT не переносится автоматически на каждый EP.**
DirectML уже в sustained engineering, OpenVINO EP активен, но отстаёт от
ORT и OpenVINO, CoreML остаётся Preview, а удалённый ROCm EP сменяется
MIGraphX.
- **Долгосрочный Intel-путь — нативный OpenVINO/OpenVINO GenAI.** Он имеет
собственную release/LTS policy, развивает Whisper и NPU и уже предоставляет
word-level timestamps. OpenVINO EP полезен как мост для ONNX-моделей, но не
даёт автоматически последние возможности нативного стека.
- **Новый Windows-слой — Windows ML, а не DirectML.** Windows ML остаётся ORT,
но добавляет обнаружение устройств и управляемый каталог vendor EP. Для AMD
там доступен MIGraphX; Python-приложению всё равно нужны bootstrap, загрузка
и явная регистрация EP.
- **На AMD и Apple готовый пакет ещё не означает ускорение конкретной модели.**
Linux AMD имеет wheel MIGraphX для CPython 3.13, Apple Silicon — CoreML EP в
обычном ORT wheel; полный offload GigaAM и моделей диаризации не подтверждён
ни для одного из этих путей.
- **Браузеры используют ту же архитектурную идею:** ORT Web даёт единый API,
WASM — переносимый CPU baseline, WebGPU/WebNN — опциональные ускорители с
ограниченным набором операторов и fallback. Сам ONNX-файл не устраняет
различия preprocessing, decoding и доступных kernels.
- **Главная находка для карты диаризации:** OpenVINO GenAI уже умеет возвращать
пословные таймкоды. Их отсутствие в результате `local-transcriber` — пробел
проектного `Backend`/`TranscribeResult`, а не ограничение OpenVINO.
Общий принцип: поддержка пути доказана только тогда, когда подтверждены
поставка, создание сессии, фактическое размещение графа, сохранение выходного
контракта и end-to-end стоимость. Наличие wheel или имени EP закрывает только
первый из этих пунктов.
## Как устроены исследуемые слои
Сравниваемые названия относятся к разным уровням и не являются
взаимозаменяемыми пакетами.
| Слой | Роль | Что фиксирует приложение |
|---|---|---|
| ONNX | Формат графа, операторов и типов данных | Артефакт модели и opset ([ONNX About](https://onnx.ai/about)) |
| ONNX Runtime | Движок, который загружает ONNX-граф и распределяет узлы между EP | API сессии, версия ORT и порядок EP ([архитектура ORT](https://onnxruntime.ai/docs/reference/high-level-design.html)) |
| Execution Provider | Адаптер ORT к CPU, GPU или NPU; получает только поддержанные узлы/подграфы | Аппаратный runtime, provider options и CPU fallback ([архитектура EP](https://onnxruntime.ai/docs/execution-providers/)) |
| Windows ML | Windows-поставка ORT с каталогом, установкой и обновлением vendor EP | Windows App SDK, deployment mode и политика выбора EP ([обзор](https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/overview)) |
| OpenVINO | Runtime, компилятор и device plugins для CPU/GPU/NPU; читает в том числе ONNX | API OpenVINO, устройство и поддержанные форматы ([поддержанные модели](https://docs.openvino.ai/2026/documentation/compatibility-and-support/supported-models.html)) |
| OpenVINO GenAI | Высокоуровневые pipelines поверх OpenVINO, включая Whisper и общий ASR API | OpenVINO IR, pipeline API и согласованные версии компонентов ([GenAI PyPI](https://pypi.org/project/openvino-genai/2026.3.0.0/)) |
| ORT Web | Отдельная JavaScript/WebAssembly-поставка ORT для браузера | JS API, WASM runtime и browser EP ([обзор ORT Web](https://onnxruntime.ai/docs/tutorials/web/)) |
Один ONNX-артефакт можно исполнять обычным ORT CPU EP, передавать его
поддержанные подграфы OpenVINO EP или загружать напрямую в OpenVINO. Результат
различается по покрытию операторов, квантованию, fallback и производительности
([ORT partitioning](https://onnxruntime.ai/docs/execution-providers/),
[OpenVINO EP coverage](https://onnxruntime.ai/docs/execution-providers/OpenVINO-ExecutionProvider.html),
[чтение ONNX в OpenVINO](https://docs.openvino.ai/2026/openvino-workflow/model-preparation/convert-model-onnx.html)).
### Лестница доказательства
Для каждой пары «модель × устройство × движок» используются пять уровней:
1. **Поставка:** существует совместимый wheel/runtime.
2. **Загрузка:** все модельные сессии создаются без ошибки.
3. **Размещение:** profiler показывает, какие узлы действительно исполняет EP,
а какие ушли в CPU fallback.
4. **Контракт:** текст, таймкоды, сегменты и эмбеддинги остаются допустимыми.
5. **Пригодность:** end-to-end скорость, память и качество проходят проектную
приёмку.
Ниже «подтверждено» означает прямое upstream-обещание или локальный результат;
«вывод» следует из архитектуры, но не проверен на конкретной модели;
«эксперимент» означает, что неизвестен хотя бы один уровень после поставки.
## Жизненный цикл движков и аппаратных путей
### ONNX Runtime core
ORT core активно развивается. Версии 1.26, 1.27 и 1.28 вышли 8 мая, 19 июня и
25 июля 2026 года; в них продолжалось развитие plugin EP API, ядра,
безопасности и аппаратных провайдеров
([1.26.0](https://github.com/microsoft/onnxruntime/releases/tag/v1.26.0),
[1.27.0](https://github.com/microsoft/onnxruntime/releases/tag/v1.27.0),
[1.28.0](https://github.com/microsoft/onnxruntime/releases/tag/v1.28.0)).
Официальные страницы расходятся в обещанном cadence: servicing-документ говорит
о full releases примерно раз в квартал, roadmap — о ежемесячных релизах и
промежуточных patch-релизах. Публичной LTS/EOL policy нет, поэтому текущий
почти месячный темп нельзя считать гарантией
([servicing](https://onnxruntime.ai/docs/reference/releases-servicing.html),
[roadmap](https://onnxruntime.ai/roadmap),
[support policy](https://github.com/microsoft/onnxruntime/blob/main/SUPPORT.md)).
С ORT 1.23 новые EP рекомендуется делать отдельными plugins. В 1.241.28 API
получил prepacking, EP Context, zero-copy I/O, profiling и model packages
([инструкция для нового EP](https://onnxruntime.ai/docs/execution-providers/add-execution-provider.html),
[1.24.1](https://github.com/microsoft/onnxruntime/releases/tag/v1.24.1),
[1.28.0](https://github.com/microsoft/onnxruntime/releases/tag/v1.28.0)). Это
укрепляет ORT как общий движок, но одновременно отделяет lifecycle конкретного
ускорителя от lifecycle ядра.
### Нативный OpenVINO и OpenVINO GenAI
OpenVINO публикует несколько регулярных релизов в год. Каждый поддерживается до
следующего, а последняя версия года становится LTS: security updates выходят
два года либо до двух следующих LTS, исправления новых bugs — один год.
Preview-компоненты этой гарантией не покрываются
([release notes](https://docs.openvino.ai/2026/about-openvino/release-notes-openvino.html),
[release policy](https://docs.openvino.ai/2026/about-openvino/release-notes-openvino/release-policy.html)).
OpenVINO GenAI — pipeline-библиотека поверх OpenVINO и OpenVINO Tokenizers.
Их `major.minor.patch` должны совпадать; разъезд версий может привести к
ABI/import errors. PyPI wheel нельзя смешивать с C++ archive другого ABI
([правила совместимости](https://pypi.org/project/openvino-genai/2026.3.0.0/)).
Whisper остаётся активным направлением:
- OpenVINO 2026.0 добавил word-level timestamps в `WhisperPipeline` на CPU,
GPU и NPU; 2026.3 добавил язык в результат
([2026.0](https://docs.openvino.ai/2026/about-openvino/release-notes-openvino.html#openvino-2026-0-0),
[2026.3](https://docs.openvino.ai/2026/about-openvino/release-notes-openvino.html#openvino-2026-3-0));
- OpenVINO 2026.3 ввёл общий `ASRPipeline` и Qwen3-ASR, расширив speech API за
пределы Whisper ([2026.3](https://docs.openvino.ai/2026/about-openvino/release-notes-openvino.html#openvino-2026-3-0));
- удалён только ранее deprecated stateless Whisper decoder; рекомендуемый путь
использует stateful model
([deprecations](https://docs.openvino.ai/2026/about-openvino/release-notes-openvino.html#deprecation-and-support)).
NPU — полноценное устройство OpenVINO, но требует отдельного driver, работает
со static shapes, а совместимость compiled blobs между версиями не
гарантируется. `WhisperPipeline` поддерживает NPU, однако целевой Core i5 11-го
поколения NPU не имеет: для него OpenVINO означает CPU/iGPU
([NPU device](https://docs.openvino.ai/2026/openvino-workflow/running-inference/inference-devices-and-modes/npu-device.html),
[Whisper on NPU](https://docs.openvino.ai/2026/openvino-workflow-generative/inference-with-genai/inference-with-genai-on-npu.html#whisper-inference-on-npu),
[AUTO priority](https://docs.openvino.ai/2026/openvino-workflow/running-inference/inference-devices-and-modes/auto-device-selection.html)).
### Аппаратные пути ORT
| Путь | Состояние на 2026-08-12 | Практическое следствие |
|---|---|---|
| CPU EP | Часть ORT core, production baseline | Самая широкая поставка; аппаратного ускорителя не обещает |
| DirectML EP | Sustained engineering; feature development перешёл в Windows ML | Поддерживается, но не подходит как новый долгосрочный GPU default ([DirectML EP](https://onnxruntime.ai/docs/execution-providers/DirectML-ExecutionProvider.html)) |
| Windows ML | Production-поставка ORT для Windows с управляемым каталогом EP | Стратегический Windows-слой, но требует platform-specific bootstrap ([deployment](https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/distributing-your-app)) |
| OpenVINO EP | Активен; deprecated только часть старых provider options | Мост к Intel-ускорению, но готовый wheel отстаёт от ORT/OpenVINO ([OpenVINO EP](https://onnxruntime.ai/docs/execution-providers/OpenVINO-ExecutionProvider.html)) |
| MIGraphX EP | Активный AMD-путь; прежний ROCm EP удалён из ORT 1.23 | Долгосрочнее ROCm EP, но зависит от ROCm/GPU/OS ([ORT 1.23](https://github.com/microsoft/onnxruntime/releases/tag/v1.23.0)) |
| CoreML EP | Preview | Доступен в macOS ORT wheel, но требует проверки partitioning ([CoreML EP](https://onnxruntime.ai/docs/execution-providers/CoreML-ExecutionProvider.html)) |
| Native WebGPU EP | Новый plugin поверх Dawn/D3D12/Vulkan/Metal | Кросс-вендорный кандидат; browser WebGPU использует другой runtime path ([WebGPU EP](https://onnxruntime.ai/docs/execution-providers/WebGPU-ExecutionProvider.html)) |
#### DirectML и Windows ML
DirectML EP использует DirectML 1.15.2, поддерживает ONNX только до opset 20 и
не допускает parallel execution одной session. Последний
`onnxruntime-directml` на дату среза — 1.24.4, тогда как ORT core уже 1.28.0.
Исправления DML всё ещё входят в ORT, то есть sustained engineering означает
поддержку без прежнего feature cadence, а не удаление
([DirectML EP](https://onnxruntime.ai/docs/execution-providers/DirectML-ExecutionProvider.html),
[PyPI](https://pypi.org/project/onnxruntime-directml/),
[ORT 1.28](https://github.com/microsoft/onnxruntime/releases/tag/v1.28.0)).
Windows ML не меняет формат модели и не заменяет ORT: runtime содержит
`onnxruntime.dll`, DirectML и Windows ML API. Новый слой добавляет обнаружение
устройств, каталог vendor EP, их установку, регистрацию и обновление. DirectML
остаётся встроенным legacy EP; MIGraphX, VitisAI, OpenVINO, QNN и
NvTensorRtRtx поставляются через каталог или вместе с приложением
([обзор](https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/overview),
[состав runtime](https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/distributing-your-app),
[каталог EP](https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/supported-execution-providers)).
Python-пакет называется `onnxruntime-windowsml`. Версия
`1.27.1.202607110137` имеет статус `Production/Stable`, требует Python 3.11+ и
публикует `cp313` wheels для Windows x86-64 и ARM64
([PyPI](https://pypi.org/project/onnxruntime-windowsml/)). Отдельная ONNX
Runtime GenAI Windows ML library 0.x остаётся Preview; её статус не относится
к обычному ONNX-инференсу
([GenAI Preview](https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/run-genai-onnx-models)).
Для Python поддержан только framework-dependent unpackaged deployment: нужны
Windows App SDK Runtime и bootstrap packages. Динамический каталог аппаратных
EP требует Windows 11 24H2 build 26100+
([get started](https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/get-started),
[deployment](https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/distributing-your-app)).
Приложение должно скачать выбранный EP через `ensure_ready_async()` и
зарегистрировать библиотеку в ORT; `EnsureAndRegisterCertifiedAsync()` не
регистрирует EP в Python environment
([инициализация EP](https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/initialize-execution-providers)).
#### OpenVINO EP
OpenVINO EP не объявлен deprecated или maintenance-only. Intel продолжает
публиковать пакет, а ORT 1.26 и 1.28 содержат его изменения. Но последний
готовый `onnxruntime-openvino` 1.24.1 включает OpenVINO 2025.4.1 на Linux и
требует отдельный OpenVINO на Windows. Нативный OpenVINO уже достиг 2026.3, ORT
core — 1.28
([PyPI](https://pypi.org/project/onnxruntime-openvino/1.24.1/),
[матрица совместимости](https://onnxruntime.ai/docs/execution-providers/OpenVINO-ExecutionProvider.html),
[ORT 1.26](https://github.com/microsoft/onnxruntime/releases/tag/v1.26.0),
[ORT 1.28](https://github.com/microsoft/onnxruntime/releases/tag/v1.28.0)).
Следствие: EP остаётся рабочим мостом для ONNX-моделей, но не является способом
автоматически получить последние Whisper/NPU-возможности OpenVINO. Deprecated
provider options, заменённые `load_config`, не означают deprecation самого EP.
## Практическая поставка по платформам
Срез сделан по фактическим wheel, а не только по classifiers.
| Платформа и устройство | Готовый runtime/EP | Статус | `cp313` | Текущий CLI без новой интеграции |
|---|---|---|---|---|
| Intel x86 CPU | ORT CPU; OpenVINO CPU | Production | Да | Оба пути уже есть |
| Intel GPU/NPU | Нативный OpenVINO | Production; часть NPU-функций Preview | Да, Windows/Linux x86-64 | OpenVINO GPU есть; NPU потребует нового device profile |
| Windows, AMD CPU | ORT CPU | Production baseline | Да, `win_amd64` | Да |
| Windows, AMD GPU | DirectML | Sustained engineering | Да, `onnxruntime-directml` | Нужен новый provider/device UX |
| Windows 11 24H2+, AMD GPU | Windows ML + MIGraphX | Windows ML production; EP зависит от driver/device | Да, `onnxruntime-windowsml` | Нужны bootstrap и регистрация EP |
| Linux, AMD CPU | ORT CPU | Production baseline | Да, manylinux x86-64 | Да |
| Linux, AMD GPU | MIGraphX | Активная замена удалённого ROCm EP | Да, `onnxruntime-migraphx 1.27.1` | Нужны ROCm stack и provider integration |
| Apple Silicon, CPU | ORT CPU | Production baseline | Да, macOS 14 ARM64 | Да |
| Apple Silicon, GPU/ANE | CoreML EP | Preview | Да, в обычном ORT wheel | Нужны provider integration и profiling |
| Apple Silicon, CPU | OpenVINO GenAI Whisper | Production package; CPU-only на macOS | Да | Текущий dependency marker исключает macOS |
Обычный `onnxruntime` 1.28.0 поставляет `cp313` wheels для Windows x86-64 и
ARM64, Linux x86-64 и ARM64, macOS 14 ARM64
([files](https://pypi.org/project/onnxruntime/1.28.0/#files)). Для сравнения,
`onnxruntime-directml` 1.24.4 ограничен Windows x86-64, а
`onnxruntime-openvino` 1.24.1 — Windows/Linux x86-64
([DirectML files](https://pypi.org/project/onnxruntime-directml/1.24.4/#files),
[OpenVINO EP files](https://pypi.org/project/onnxruntime-openvino/1.24.1/#files)).
### AMD
На AMD x86 CPU поддерживаемая опора — ORT CPU EP. OpenVINO 2026.3 официально
перечисляет Intel и ARM/Apple CPU, но не AMD x86; наличие x86 wheel само по себе
не является обещанием поддержки AMD
([OpenVINO requirements](https://docs.openvino.ai/2026/about-openvino/release-notes-openvino/system-requirements.html)).
Под Windows DirectML поддерживает AMD GCN первого поколения и новее, но его
ограниченный lifecycle делает Windows ML + MIGraphX более перспективным путём.
Текущий Windows ML MIGraphX требует совместимый GPU/driver и не поддерживает
GenAI scenarios; применимость этой формулировки к GigaAM RNN-T не определена и
должна проверяться экспериментом
([Windows ML EP](https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/supported-execution-providers)).
Под Linux прежний ROCm EP удалён из ORT 1.23. Пакет `onnxruntime-rocm`
1.22.2.post3 всё ещё имеет `cp313`, но закреплён на ветке до удаления EP.
Активный `onnxruntime-migraphx` 1.27.1 публикует
`cp313-manylinux_2_34_x86_64`; реальные ограничения теперь лежат в ROCm/GPU/OS
и покрытии графа
([ROCm PyPI JSON](https://pypi.org/pypi/onnxruntime-rocm/json),
[MIGraphX PyPI JSON](https://pypi.org/pypi/onnxruntime-migraphx/json),
[MIGraphX EP](https://onnxruntime.ai/docs/execution-providers/MIGraphX-ExecutionProvider.html)).
### Apple Silicon
ORT CPU — готовый baseline. `onnx-asr` документирует CoreML в обычном
`onnxruntime` package. CoreML EP может использовать CPU, GPU и Apple Neural
Engine через `MLComputeUnits`, но забирает только поддержанные подграфы.
Dynamic shapes могут быть дорогими; offload внутри `Loop`/`Scan`/`If` по
умолчанию выключен. `ProfileComputePlan` позволяет увидеть размещение
([onnx-asr installation](https://istupakov.github.io/onnx-asr/installation/),
[CoreML EP](https://onnxruntime.ai/docs/execution-providers/CoreML-ExecutionProvider.html)).
OpenVINO/OpenVINO GenAI имеют `cp313-macosx_11_0_arm64` wheels и поддерживают
Apple Silicon, но на macOS исполняются только на CPU. GPU plugin рассчитан на
Intel GPU, NPU plugin — на Intel NPU
([OpenVINO requirements](https://docs.openvino.ai/2026/about-openvino/release-notes-openvino/system-requirements.html),
[OpenVINO GenAI files](https://pypi.org/project/openvino-genai/2026.3.0.0/)).
## Возможности текущих моделей
Таблица применяет одну и ту же лестницу доказательства к трем модельным путям.
| Модель и требуемый контракт | Переносимый baseline | Intel accelerator | AMD accelerator | Apple accelerator | Browser |
|---|---|---|---|---|---|
| GigaAM v3 E2E RNN-T: текст + token timestamps | **Подтверждено:** `onnx-asr` + ORT CPU на x86/ARM | OpenVINO EP или конверсия в IR — **эксперимент** | DirectML/WinML MIGraphX/Linux MIGraphX — **эксперимент** | CoreML — **эксперимент** | Нужен порт Python preprocessing/decoder и проверка kernels |
| OpenVINO GenAI Whisper: текст + word timestamps | Нативный OpenVINO CPU на поддержанных платформах | **Подтверждено:** OpenVINO CPU/GPU/NPU | AMD GPU не поддержан; AMD CPU не входит в official hardware | **Подтверждено:** только OpenVINO CPU | Это другой runtime/model artifact; не подтверждено |
| PyAnnote segmentation + WeSpeaker embeddings: интервалы + кластеры | **Подтверждено локально:** sherpa-onnx + ORT CPU на одной записи | OpenVINO EP/native — **эксперимент** | DML/MIGraphX — **эксперимент**, для sherpa может потребоваться rebuild | CoreML — **эксперимент** | sherpa имеет WASM demo, но выбранная пара моделей не подтверждена |
### GigaAM E2E RNN-T
`onnx-asr` работает на x86/ARM CPU и перечисляет CoreML, DirectML, ROCm и
WebGPU. GigaAM создаёт обычные ORT-сессии encoder/decoder/joint, поэтому смена
EP архитектурно возможна
([onnx-asr](https://istupakov.github.io/onnx-asr/),
[installation](https://istupakov.github.io/onnx-asr/installation/),
[model card](https://huggingface.co/istupakov/gigaam-v3-onnx)). Но это не
доказывает operator coverage или полный offload конкретного E2E RNN-T.
Таймкоды формирует `onnx-asr.with_timestamps()` из тензорных выходов модели.
Если EP сохраняет эти выходы, `TimestampedResult` должен сохраниться — это
**вывод**, который требует golden test. Mixed precision, graph transforms и
CPU fallback могут менять численные результаты
([timestamps API](https://istupakov.github.io/onnx-asr/usage/),
[архитектура пакета](https://github.com/istupakov/onnx-asr/tree/v0.12.0)).
Официальный ONNX helper исходного GigaAM проверяет только text parity и теряет
emission frames. Поэтому проверять нужно именно контракт `onnx-asr`, а не
произвольный GigaAM ONNX export
([GigaAM](https://github.com/salute-developers/GigaAM),
[ONNX parity test](https://github.com/salute-developers/GigaAM/blob/main/tests/test_onnx.py),
[ONNX helper](https://github.com/salute-developers/GigaAM/blob/main/gigaam/onnx_utils.py)).
### OpenVINO Whisper
OpenVINO GenAI подтверждает Whisper tiny/base/small/medium/large-v3 и
Distil-Whisper. Word timestamps доступны на CPU/GPU/NPU, stateful model
обязателен
([ASR guide](https://openvinotoolkit.github.io/openvino.genai/docs/use-cases/speech-recognition/),
[supported models](https://openvinotoolkit.github.io/openvino.genai/docs/supported-models/)).
Это наиболее доказанный accelerator-путь из рассматриваемых, но только для
поддержанного OpenVINO hardware. На Apple Silicon он остаётся CPU-путём; на AMD
GPU не работает.
Проектный OpenVINO backend уже запрашивает timestamps, но сводит результат к
chunk-сегментам. Поэтому для диаризации нужно сначала определить и протянуть
word-level контракт через `Backend`/`TranscribeResult`.
### PyAnnote + WeSpeaker для диаризации
Локальная разведка доказала, что связка PyAnnote segmentation + WeSpeaker
embeddings создаёт интервалы на CPU ORT для одной записи. Это закрывает базовую
совместимость, но не upstream-гарантию пары и не переносимость на другие EP
([разведка](../benchmarks/2026-08-12-diarization-feasibility.md)).
Официальный sherpa recipe перечисляет PyAnnote с 3D-Speaker или NeMo
embeddings, а WeSpeaker публикует собственные ONNX-модели. Поэтому на новом EP
нужно отдельно проверять обе сессии и полный pipeline
([sherpa models](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/models.html),
[WeSpeaker models](https://github.com/wenet-e2e/wespeaker/blob/master/docs/pretrained.md)).
Итоговые сегменты создаёт sherpa после двух ONNX-моделей и clustering. Ускорение
одной сессии не означает ускорение pipeline; численные изменения эмбеддингов
могут изменить кластеры даже при совпадающем текстовом контракте
([C API](https://k2-fsa.github.io/sherpa/onnx/c-api/html/speaker_diarization.html)).
## Почему ONNX Runtime Web работает между браузерами
Браузерная переносимость появляется не из ONNX-файла отдельно, а из сочетания
трёх решений:
1. ONNX задаёт общий сериализованный граф.
2. ORT Web даёт один JavaScript `InferenceSession` API.
3. WASM служит широким CPU baseline, а WebGPU/WebNN подключаются как
ускорители с fallback на WASM.
На 2026-08-12 официальный browser matrix выглядит так
([матрица](https://onnxruntime.ai/docs/get-started/with-javascript/web.html)):
- WASM работает в Chrome/Edge, Safari и Firefox на основных desktop/mobile
платформах и имеет наиболее полное покрытие операторов;
- WebGPU поддерживается Chromium на Windows/macOS/Android, остаётся experimental
в ORT Web и имеет собственный operator subset;
- WebNN experimental и в официальной матрице требует feature flag в
Chrome/Edge Windows; неподдержанные узлы могут уйти в WASM
([WebNN guide](https://onnxruntime.ai/docs/tutorials/web/ep-webnn.html));
- WebGL находится в maintenance mode.
Один model artifact не означает одинаковую работоспособность. WebGPU имеет
отдельную [таблицу операторов](https://github.com/microsoft/onnxruntime/blob/main/js/web/docs/webgpu-operators.md),
а preprocessing и decoding остаются кодом приложения. Большие модели упираются
примерно в 2 GB для ArrayBuffer/Protobuf и 4 GB WebAssembly memory; external
data нужно загружать отдельно
([large models](https://onnxruntime.ai/docs/tutorials/web/large-models.html)).
WASM threading требует `crossOriginIsolated`; proxy worker несовместим с
WebGPU, а dynamic shapes и CPU fallback ограничивают graph capture
([environment flags](https://onnxruntime.ai/docs/tutorials/web/env-flags-and-session-options.html),
[WebGPU guide](https://onnxruntime.ai/docs/tutorials/web/ep-webgpu.html)).
`onnx-asr` заявляет WebGPU для **native Python package**. Это не browser port:
GigaAM потребует JavaScript preprocessing/decoder, загрузки нескольких
артефактов и проверки kernels. У sherpa-onnx есть отдельная однопоточная WASM
speaker-diarization demo, но она не доказывает работу проектной пары PyAnnote +
WeSpeaker через ORT Web WebGPU
([onnx-asr installation](https://istupakov.github.io/onnx-asr/installation/),
[sherpa JS diarization](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/javascript.html)).
Native WebGPU EP также не равен browser WebGPU: Python plugin использует Dawn
поверх D3D12/Vulkan/Metal, ORT Web — browser JSEP/WASM path
([native WebGPU EP](https://onnxruntime.ai/docs/execution-providers/WebGPU-ExecutionProvider.html),
[plugin PyPI JSON](https://pypi.org/pypi/onnxruntime-ep-webgpu/json)).
Полезный для CLI вывод из браузерной архитектуры — не новый продукт, а строгая
политика capabilities:
- всегда сохранять переносимый CPU baseline;
- обнаруживать ускоритель во время запуска;
- различать наличие API, успешную сессию, размещение графа и сохранение
контракта;
- измерять end-to-end pipeline, а не отдельное имя provider.
## Что это меняет для `local-transcriber`
1. **CPU ORT остаётся переносимым baseline.** Он не зависит от затухающего EP и
обеспечивает самый широкий CPython/platform coverage для GigaAM и
диаризации.
2. **Нативный OpenVINO остаётся отдельным долгосрочным Intel ASR-путём.** Его
не следует заменять OpenVINO EP только ради единого ORT API: EP отстаёт и не
даёт автоматически pipeline-возможности OpenVINO GenAI.
3. **Пословная диаризация на `openvino-*` технически достижима.** Upstream уже
возвращает word timestamps; карта должна решить контракт и fallback, а не
считать отсутствие таймкодов свойством движка.
4. **AMD/Apple acceleration нельзя добавлять по факту наличия wheel.** Сначала
нужны model-specific smoke/profile/golden tests; только затем device UX и
dependency markers.
5. **Диаризацию не нужно связывать с немедленным выбором аппаратного EP.**
Переносимый CPU-вариант может быть специфицирован независимо; ускорение двух
моделей — отдельная работа.
6. **Консолидация на ORT из исследования не следует.** FasterWhisper сохраняет
CUDA и языковое покрытие, нативный OpenVINO — актуальный Intel ASR API, ORT —
переносимый ONNX-путь.
Для текущей карты это даёт два входа:
- [«Выбрать единицу привязки спикера к тексту»](https://git.dementev.space/ddmitry/local-transcriber/issues/14)
должен назвать timestamp-aware изменение `Backend`/`TranscribeResult`;
- [«UX диаризации: флаг, число участников, зависимость и поведение на OpenVINO»](https://git.dementev.space/ddmitry/local-transcriber/issues/16)
должен определить поведение там, где конкретный backend/model не отдаёт
нужных таймкодов.
Реализация и приёмка WinML, MIGraphX, CoreML и browser-путей остаются за
пунктом назначения карты.
## Минимальная экспериментальная матрица
Будущий platform experiment должен использовать один 5–10-минутный fixture с
перекрывающейся речью и зафиксированным CPU output.
| Объект | Сравнение | Что фиксировать |
|---|---|---|
| GigaAM E2E RNN-T | ORT CPU против DirectML, WinML MIGraphX, Linux MIGraphX, CoreML | Создание всех сессий, node placement, CPU fallback, текст, token timestamps, численное расхождение |
| PyAnnote segmentation | Те же EP отдельно от остального pipeline | Placement, интервалы и расхождение выходных тензоров |
| WeSpeaker embeddings | Те же EP отдельно | Placement, cosine drift и влияние на clustering |
| Полная диаризация | CPU baseline против каждого прошедшего EP | Число спикеров, границы, стабильность кластеров, время и память |
| OpenVINO Whisper | Intel CPU/GPU/NPU и Apple CPU | Word timestamps, проектный adapter, время и память |
| Browser, только если станет целью | WASM baseline против WebGPU/WebNN | Размер артефактов, kernels/fallback, preprocessing/decoder и память |
Путь можно предлагать пользователю только после прохождения всех пяти уровней
доказательства; выигрыш отдельной ONNX-сессии не считается выигрышем
end-to-end транскрипции или диаризации.
## Открытые вопросы после исследования
### Внутри карты диаризации
- Какой точный word/token timestamp contract нужен formatter и объединению с
интервалами спикеров?
- Как представить capability backend/model и какое fallback-поведение выбрать,
если пословных таймкодов нет?
### Отдельные будущие работы
- Проходят ли GigaAM E2E RNN-T, PyAnnote и WeSpeaker все пять уровней на
DirectML, Windows ML MIGraphX, Linux MIGraphX и CoreML?
- Насколько устойчива локально работающая пара PyAnnote + WeSpeaker между EP,
если upstream recipe её не фиксирует?
- Окупает ли Windows ML bootstrap/catalog преимущество над низкофрикционным,
но legacy DirectML?
- Даёт ли OpenVINO EP выигрыш моделям диаризации на целевом Intel Core i5 после
учёта fallback, загрузки и памяти?
- Нужен ли отдельный browser experiment, или браузерная ветка остаётся только
архитектурным примером переносимого baseline и опциональных ускорителей?
@@ -0,0 +1,207 @@
# Диаризация говорящих в транскрипте
## Проблема
Текущий транскрипт знает только сегменты распознавания. Их границы проходят по
тишине и не совпадают со сменой говорящего, поэтому один сегмент может содержать
несколько реплик. Назначение одной метки всему сегменту искажает структуру
диалога и делает транскрипт слабым сырьём для конспекта или протокола встречи.
[ADR-007](../adr/007-word-level-speaker-diarization.md) выбирает явную
пословную диаризацию через `sherpa-onnx`. Эта спецификация фиксирует форму первой
реализации, не меняя принятые решения.
## Цели
- По явному запросу строить реплики говорящих, сохраняя текст и длинный контекст
ASR.
- Поддержать один контракт слов с временной привязкой на FasterWhisper,
ONNX-ASR и OpenVINO.
- Сохранить предсказуемый single- и batch-режим при отсутствии речи, ошибках
диаризации и малых голосовых кластерах.
- Выдать компактный Markdown, удобный и человеку, и последующей обработке LLM.
## Не входит
- Автоматическое включение диаризации без флага и `--no-diarize`.
- Параллельный запуск ASR и диаризации.
- Сопоставление `Speaker N` с именами участников.
- Постоянный кеш разметки говорящих, голосовые эмбеддинги и диагностические
файлы.
- Отдельный синтаксис для перекрывающейся речи.
- Изменение устройства ASR или диаризации ради восстановления функции.
## Пользовательский интерфейс
- `--diarize` включает диаризацию. По умолчанию она выключена; ключ в
`.transcriber.toml` в первой версии не добавляется.
- `--speakers N`, где `N >= 1`, задаёт известное число участников и сам включает
диаризацию. Без него число кластеров определяется автоматически.
- `--threads N` остаётся единым бюджетом активного CPU-прохода. Значение целиком
получает сначала ASR, затем диаризация; `0` оставляет настройки библиотек.
- `--verbose` показывает в консоли прогресс, число кластеров и интервалов,
длительность прохода и предупреждения, но не создаёт дополнительные файлы.
- `--force` пересчитывает и ASR, и диаризацию. Без него готовый транскрипт, как и
сейчас, пропускается целиком.
`sherpa-onnx` входит в обычные runtime-зависимости. Модели
`sherpa-onnx-pyannote-segmentation-3-0` и
`wespeaker_en_voxceleb_resnet34_LM.onnx` скачиваются и кешируются лениво при
первом запросе диаризации. Отдельного installation extra нет.
## Контракты данных
Результат ASR сохраняет существующие сегменты распознавания и дополнительно
содержит упорядоченные канонические слова. Для каждого слова известны текст,
начало и конец на временной шкале исходной записи. Backend-специфичные токены и
слова нормализуются в адаптере бэкенда; их обратная сборка должна сохранять
распознанный текст с точностью до нормализации пробелов.
Разметка говорящих хранится отдельно от результата ASR: это упорядоченные
временные интервалы с анонимным идентификатором голосового кластера. ASR-бэкенд
не знает о кластерах, а диаризатор не знает о распознанном тексте.
Операция сведения суммирует временное перекрытие слова с интервалами каждого
кластера и назначает кластер с единственным наибольшим ненулевым перекрытием.
Если пересечения нет либо несколько кластеров делят наибольшее значение, слово
получает неизвестного говорящего: порядок кластеров не используется как
искусственная развязка ничьей. Соседние слова одного говорящего объединяются в
реплику; порядок слов и исходная временная шкала не меняются.
Все три ASR-пути обязаны предоставлять пословный контракт до включения
диаризации:
| Путь | Источник временных привязок |
|---|---|
| FasterWhisper | word timestamps CTranslate2 |
| ONNX-ASR | timestamped result модели |
| OpenVINO | word-level timestamps `WhisperPipeline` |
Грубая подстановка метки на весь сегмент распознавания запрещена.
## Пайплайн и время жизни
После prescan и только при наличии файлов для обработки загружаются ASR-модель
и один batch-owned диаризатор. До первого ASR проверяются доступность пословных
таймкодов, модели диаризации и возможность создать диаризатор. Ошибка этого
этапа останавливает весь запуск без частичных транскриптов.
Каждый файл обрабатывается последовательно:
1. ASR;
2. диаризация, если ASR нашёл речь;
3. сведение слов с разметкой говорящих;
4. форматирование и запись Markdown.
Один диаризатор последовательно переиспользуется для всех файлов батча. Данные
конкретной записи не становятся состоянием следующей. Объект освобождается при
завершении команды и не переносится через `TranscribeFileResult`.
Разметка говорящих хранится только до сведения. Единственный постоянный
продуктовый артефакт — Markdown-транскрипт; локальный кеш файлов моделей живёт
по существующим правилам загрузчиков.
## Конфигурация диаризации
Автоматический режим использует:
- Pyannote segmentation 3.0;
- WeSpeaker ResNet34 LM;
- порог кластеризации 0,89;
- автоматическое число кластеров.
`--speakers N` передаёт явное число кластеров вместо автоматического. Для
предупреждения используется диагностическая граница из калибровки: малым
считается кластер с речью короче максимума из 5 секунд и 2% длительности записи.
Граница влияет только на предупреждение — кластер не отбрасывается, получает
обычный номер и не меняет статус команды.
## Формат Markdown
При двух и более найденных кластерах тело состоит из линейных реплик:
```markdown
[09:07] Speaker 1: Мы же у них не разворачиваемся…
[09:18] Speaker 2: Мне гораздо проще накатывать обновления…
```
- Печатается только начало реплики; доли секунды отбрасываются, а не округляются
(`09:07.96``[09:07]`). Для записей длиннее часа используется
`[HH:MM:SS]`, иначе `[MM:SS]`.
- Метка `Speaker N` не получает Markdown-выделение.
- Нумерация начинается заново для каждого файла; номера назначаются по порядку
первого появления кластера в словах транскрипта.
- Смена говорящего всегда начинает новую реплику.
- Речь одного говорящего дополнительно разбивается по паузе не меньше 2 секунд
и максимальной длительности реплики 60 секунд.
- Слова без назначенного кластера группируются под `Speaker ?`.
- Перекрывающаяся речь остаётся в хронологическом порядке без особого
синтаксиса.
- В шапку добавляется число голосовых кластеров и предупреждения. Таблица
длительности по кластерам не выводится.
Без успешной разметки нескольких говорящих сохраняется нынешний формат
обычного транскрипта с диапазонами времени.
## Деградация и статус команды
| Ситуация | Артефакт | Консоль и шапка | Статус |
|---|---|---|---|
| Диаризация не запрошена | Обычный транскрипт | Без новых сообщений | Текущий |
| ASR не нашёл речь | Текущий пустой Markdown | Речь не обнаружена; диаризация не запускалась | 0 |
| Найдено не меньше двух кластеров | Транскрипт с `Speaker N` | Число кластеров и предупреждения | 0, если нет иной ошибки |
| Найден один кластер | Обычный транскрипт без `Speaker 1` | Причина в консоли и шапке | Ненулевой |
| Есть слова без пересечения | Транскрипт с `Speaker ?` | Число таких слов | 0 |
| Есть малый кластер | Транскрипт со всеми кластерами | Длительность малого кластера | 0 |
| Разметка пуста при непустом ASR | Обычный транскрипт | Явное предупреждение | Ненулевой |
| Ошибка диаризации конкретного файла | Обычный транскрипт | Явное предупреждение | Ненулевой |
| Нет пословных таймкодов или не инициализировался диаризатор | Файлы не обрабатываются | Понятная ошибка до ASR | Ненулевой |
В батче деградированный файл записывается, учитывается как неуспешная
диаризация, а остальные файлы продолжают обрабатываться. Итоговый статус батча
ненулевой, если хотя бы один файл деградировал или завершился ошибкой.
## Критерии приёмки
### Автоматические проверки
- Адаптер каждого ASR-бэкенда возвращает монотонные слова с временной
привязкой; сборка слов сохраняет текст сегментов с точностью до пробелов.
- Сведение покрывает смену говорящего, отсутствие пересечения, равное
наибольшее перекрытие с результатом `Speaker ?`, пунктуацию на границе реплик
и хронологический порядок.
- Форматтер проверяется для обычных, часовых, неизвестных и малых кластеров,
`Speaker ?`, отбрасывания долей таймкода, паузы 2 секунды и предела 60 секунд.
- CLI проверяет несовместимые и граничные значения, не запускает ASR при ошибке
preflight и соблюдает всю матрицу деградации в single- и batch-режимах.
- Батч создаёт диаризатор ровно один раз, пропускает его для пустого ASR,
переиспользует между файлами и не пишет промежуточный кеш.
- `--threads`, `--verbose` и `--force` сохраняют описанную семантику.
Все автоматические тесты мокают движки и не скачивают реальные модели.
### Ручная проверка
Финальная сборка прогоняется на трёх записях из отчётов карты:
- текст до и после сведения совпадает с точностью до переносов и пробелов;
- на плотном диалоге вручную проверяются устойчивость «голос → кластер», смены
говорящего, пропуски, малый остаточный кластер и перекрывающаяся речь;
- автоматическая конфигурация воспроизводит наблюдённую форму результата:
три основных и один малый остаточный кластер на Data Test, по два кластера на
T2 BDMA и Yantar;
- последовательный прогон на доступном Intel baseline остаётся быстрее
реального времени; фактические wall time и peak RSS записываются рядом с
результатом проверки.
## Документация
README должен описать новые CLI-флаги, ленивую загрузку моделей, ожидаемую
стоимость, формат `Speaker N`, предупреждения и batch-поведение. Направления
«Диаризация» и «Ручка нарезки абзацев» удаляются из backlog: первое перешло в
эту спецификацию, второе закрыто разрывом реплики на смене говорящего.
После стабилизации отдельно рассматриваются
[параллельный запуск](https://git.dementev.space/ddmitry/local-transcriber/issues/22)
и [hardware-aware default](https://git.dementev.space/ddmitry/local-transcriber/issues/23).
@@ -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()