Author SHA1 Message Date
Dmitriy Dementiev 352296b84a docs(prototype): добавлен макет транскрипта со спикерами
- Зачем:
  - требовалось выбрать компактный Markdown-формат реплик до реализации диаризации.
- Что:
  - добавлены три переключаемых варианта на реальном обезличенном фрагменте.
  - выбранный линейный вариант обновлён до формата `[MM:SS] Speaker N: текст`.
- Проверка:
  - файл открыт локально, варианты A–C переключаются стрелками и параметром `variant`.
2026-08-14 14:38:42 +03:00
Dmitriy Dementiev 26d4ca2f4a docs(context): добавлены термины диаризации
- Зачем:
  - решение о пословной привязке должно использовать единый язык во всех документах карты.
- Что:
  - определены сегмент распознавания и слово с временной привязкой.
  - реплика говорящего отделена от единицы запуска модели распознавания.
- Проверка:
  - git diff --check.
2026-08-14 14:16:01 +03:00
Dmitry Dementiev bbc2bdacfe docs(diarization): уточнены условия замера Intel
- Зачем:
  - результат benchmark нужно интерпретировать с учётом ограничения питания ноутбука.
- Что:
  - зафиксировано урезанное питание во время прогонов.
  - визуальная оценка потери производительности около 30% явно отделена от измеренных результатов.
- Проверка:
  - git diff --check.
2026-08-14 13:59:33 +03:00
Dmitry Dementiev 99ddf77af3 perf(diarization): добавлен замер на старом Intel
- Зачем:
  - требовалось оценить стоимость опциональной диаризации на доступном слабом Intel baseline.
- Что:
  - зафиксированы ASR, RTFx и peak RSS на трёх контрольных записях.
  - замер peak RSS адаптирован для Linux и macOS.
  - недоступный Core i5 явно заменён Core i7-6820HQ с сохранением ограничения применимости.
- Проверка:
  - uv run ruff check .scratch/diarization/common.py.
  - uv run pytest -q: 243 passed, 1 skipped.
2026-08-14 13:37:07 +03:00
Dmitriy Dementiev b8092aade5 docs(diarization): подтверждено смешение в ASR-сегментах
- Зачем:
  - требовалось проверить долю смешанных ASR-сегментов на трёх записях, включая разговоры на двоих.
- Что:
  - добавлен отчёт с долями сегментов и времени для трёх рабочих созвонов.
  - обвязка замеров переведена на откалиброванный порог 0,89.
  - исследовательский скрипт приведён к формату ruff.
- Проверка:
  - выполнены три полных прогона bench_conflict.py с WeSpeaker и порогом 0,89.
  - uv run ruff check и ruff format --check прошли успешно.
2026-08-14 11:44:02 +03:00
Dmitriy Dementiev 0fdeebb256 docs(diarization): добавлены отчёт и скрипт калибровки
- Зачем:
  - необходим устойчивый дефолт модели эмбеддингов и порога на русской речи.
- Что:
  - задокументирован выбор WeSpeaker ResNet34 LM с порогом 0,89.
  - добавлен возобновляемый скрипт свипа моделей и параметров диаризации.
- Проверка:
  - uvx --cache-dir .uv-cache ruff check scripts/benchmarks/diarization_calibration.py.
  - uv run --cache-dir .uv-cache pytest: 243 passed, 1 skipped.
2026-08-14 11:18:17 +03:00
Dmitriy Dementiev 8c55eaa87f docs(context): добавлен термин «Опорная разметка»
- Зачем:
  - слуховая проверка (#12) показала, что разметку диаризации читают как
    истину, хотя она содержит ложный кластер, пропуски и неполные перекрытия.
- Что:
  - в глоссарий добавлен термин «Опорная разметка» с запретом на
    «эталонную», «истинную» и ground truth.
  - определение описывает роль разметки в измерении, а не результат
    конкретного прогона: выводы о пороге 0,9 остались в тикетах карты.
- Проверка:
  - git show --stat HEAD и чтение CONTEXT.md.
2026-08-12 18:08:26 +03:00
Dmitriy Dementiev 388de09c42 docs(research): переработана структура исследования
- Зачем:
  - выводы о жизненном цикле и возможностях моделей должны читаться как единое исследование.
- Что:
  - материал перестроен вокруг слоёв, платформ и уровней доказательства.
  - объединены выводы по Intel, AMD, Apple и браузерным путям.
  - подтверждённые возможности отделены от выводов и необходимых экспериментов.
- Проверка:
  - относительные ссылки и структура Markdown проверены.
  - git diff --cached --check выполнен успешно.
2026-08-12 16:25:54 +03:00
Dmitriy Dementiev 4c7f2920c1 docs(research): исследован жизненный цикл ORT и OpenVINO
- Зачем:
  - нужна фактическая опора для решений карты о диаризации на Intel-пути.
- Что:
  - исследованы жизненные циклы ORT, OpenVINO, DirectML и Windows ML.
  - сравнены пути AMD, Apple Silicon и браузерные EP для текущих моделей.
  - зафиксированы wheel-матрицы, fallback и необходимые model-specific тесты.
- Проверка:
  - git diff --cached --check.
2026-08-12 16:12:43 +03:00
11 changed files with 1636 additions and 15 deletions
+8 -6
View File
@@ -41,17 +41,18 @@ curl -sSL -O https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-rec
export PYTHONIOENCODING=utf-8
uv run python .scratch/diarization/bench_asr.py "<путь к записи>"
uv run --with sherpa-onnx python .scratch/diarization/bench_diar.py "<путь>" 8 0.9
uv run --with sherpa-onnx python .scratch/diarization/bench_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,9 — значение из
разведки, подобранное на одной записи и на ней же проверенное. На пороге 0,5
из примеров sherpa-onnx получалось 29 говорящих вместо трёх. Калибровка — это
тикет #10, до его закрытия любое значение считается временным.
- **Порог кластеризации откалиброван.** По умолчанию стоит 0,89 — единственное
проверенное значение, которое без знания числа участников дало правильные
3 / 2 / 2 кластера на трёх калибровочных фрагментах. Решение и ограничения
описаны в
[отчёте о калибровке](../../docs/benchmarks/2026-08-14-diarization-calibration.md).
- **Свип дорогой.** Каждая конфигурация — полный прогон сегментации и
эмбеддингов, около 2,5 минут на 26-минутную запись, и время от настроек
кластеризации практически не зависит. Свип вести на коротком фрагменте.
@@ -60,4 +61,5 @@ uv run --with sherpa-onnx python .scratch/diarization/bench_conflict.py "<пут
на слух — тикет #12, и он намеренно идёт до калибровки.
- **Замер памяти чинился.** В разведке `psapi.GetProcessMemoryInfo` молча
возвращал ноль; `common.peak_rss_mb()` теперь зовёт `K32GetProcessMemoryInfo`
из kernel32 и проверяет код возврата.
из kernel32 и проверяет код возврата. На Linux и macOS используется
`resource.getrusage()` с поправкой на разные единицы измерения.
+15 -4
View File
@@ -74,7 +74,13 @@ def main(audio_path: str, threshold: float, threads: int) -> None:
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))
(
seg,
major,
per_speaker[major] / total,
total - per_speaker[major],
dict(per_speaker),
)
)
n = len(rows)
@@ -95,7 +101,9 @@ def main(audio_path: str, threshold: float, threads: int) -> None:
(f"с чужой репликой (>={INTERJECTION_S:.0f} с)", lost),
("без говорящего вообще", unattributed),
):
print(f" {label:<34} {len(group):4d} {len(group) / n * 100:5.1f}% {minutes(group):5.1f} мин")
print(
f" {label:<34} {len(group):4d} {len(group) / n * 100:5.1f}% {minutes(group):5.1f} мин"
)
print()
for level in PURITY_LEVELS:
@@ -107,9 +115,12 @@ def main(audio_path: str, threshold: float, threads: int) -> None:
print("\n" + "=" * 64)
print("ХУДШИЕ 12 СЕГМЕНТОВ (больше всего чужой речи внутри):")
for seg, major, purity, others, per_speaker in sorted(attributed, key=lambda r: -r[3])[: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])
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} с) "
+14 -4
View File
@@ -22,9 +22,9 @@ EMBEDDING = MODELS / "wespeaker_en_voxceleb_resnet34_LM.onnx"
SAMPLE_RATE = 16_000
# Настройки разведки 2026-08-12. Порог 0.9 дал верное число говорящих на
# контрольной записи; на 0.5 из примеров sherpa-onnx получалось 29 вместо трёх.
DISCOVERY_THRESHOLD = 0.9
# Конфигурация, выбранная калибровкой 2026-08-14 на трёх записях.
# На 0.5 из примеров sherpa-onnx получалось 29 говорящих вместо трёх.
DISCOVERY_THRESHOLD = 0.89
DEFAULT_THREADS = 8
@@ -64,12 +64,22 @@ class _ProcessMemoryCounters(ctypes.Structure):
def peak_rss_mb() -> float | None:
"""Пиковая рабочая память процесса в МБ; None, если снять не удалось.
Два подвоха, на которых замер в разведке 2026-08-12 вернул ноль:
На 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()
+326
View File
@@ -0,0 +1,326 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PROTOTYPE — формат транскрипта со спикерами</title>
<style>
:root {
color-scheme: light;
--paper: #fbfaf7;
--ink: #25221f;
--muted: #746e67;
--line: #ddd7ce;
--accent: #9b3f2f;
--code: #f0ece5;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: #e9e4dc;
color: var(--ink);
font: 16px/1.58 system-ui, -apple-system, "Segoe UI", sans-serif;
}
main {
width: min(1180px, calc(100% - 32px));
margin: 28px auto 100px;
}
.prototype-note {
margin-bottom: 18px;
padding: 12px 16px;
border: 1px dashed #a59d92;
background: #fffdf8;
color: #5f5850;
}
.prototype-note strong { color: var(--accent); }
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(340px, .72fr);
gap: 18px;
align-items: start;
}
.paper, .source-panel {
border: 1px solid var(--line);
border-radius: 10px;
background: var(--paper);
box-shadow: 0 8px 28px rgb(49 40 31 / 9%);
}
.paper { padding: clamp(22px, 4vw, 54px); }
.source-panel { position: sticky; top: 18px; overflow: hidden; }
.source-panel h2 {
margin: 0;
padding: 13px 16px;
border-bottom: 1px solid var(--line);
color: var(--muted);
font-size: 14px;
letter-spacing: .06em;
text-transform: uppercase;
}
pre {
max-height: calc(100vh - 120px);
margin: 0;
padding: 18px;
overflow: auto;
background: var(--code);
white-space: pre-wrap;
word-break: break-word;
font: 13px/1.55 ui-monospace, "Cascadia Code", Consolas, monospace;
}
h1 { margin: 0 0 22px; font: 700 clamp(26px, 3vw, 38px)/1.15 Georgia, serif; }
h2 { margin: 28px 0 12px; font-size: 20px; }
h3 { margin: 25px 0 7px; font-size: 17px; }
ul { padding-left: 21px; }
hr { margin: 28px 0; border: 0; border-top: 1px solid var(--line); }
.turn { margin: 18px 0; }
.time { color: var(--muted); font: 13px ui-monospace, "Cascadia Code", monospace; }
.speaker { color: #783427; }
blockquote {
margin: 8px 0 22px;
padding: 10px 16px;
border-left: 4px solid #b88772;
background: #f5f0e9;
}
blockquote p { margin: 0; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th, td { padding: 9px 8px; border: 1px solid var(--line); vertical-align: top; text-align: left; }
th { background: #eee8de; }
.warning {
margin: 17px 0;
padding: 10px 13px;
border-left: 4px solid #c47b23;
background: #fff2dc;
}
.overlap { background: #f7e9e4; }
.switcher {
position: fixed;
left: 50%;
bottom: 22px;
z-index: 10;
display: flex;
align-items: center;
gap: 6px;
transform: translateX(-50%);
padding: 7px;
border: 1px solid rgb(255 255 255 / 30%);
border-radius: 999px;
background: #201d1a;
box-shadow: 0 8px 32px rgb(0 0 0 / 25%);
color: white;
}
.switcher button {
width: 38px;
height: 34px;
border: 0;
border-radius: 999px;
background: #39332e;
color: white;
cursor: pointer;
font-size: 20px;
}
.switcher button:hover { background: #554b43; }
#variant-label { min-width: 230px; text-align: center; font-size: 14px; }
@media (max-width: 820px) {
.layout { grid-template-columns: 1fr; }
.source-panel { position: static; }
pre { max-height: none; }
#variant-label { min-width: 190px; }
}
</style>
</head>
<body>
<!-- Три варианта markdown-транскрипта, переключаемые через ?variant=, в отдельном throwaway-прототипе. -->
<main>
<div class="prototype-note">
<strong>PROTOTYPE — не часть продукта.</strong>
Реальный фрагмент рабочей встречи слегка сокращён и обезличен; интервалы
и статистика взяты из уже выполненного замера. Слева — вид документа,
справа — буквальный Markdown для оценки последующей обработки ИИ.
</div>
<div class="layout">
<article class="paper" id="preview"></article>
<section class="source-panel">
<h2>Markdown-источник</h2>
<pre id="source"></pre>
</section>
</div>
</main>
<nav class="switcher" aria-label="Переключение вариантов">
<button id="previous" aria-label="Предыдущий вариант"></button>
<span id="variant-label"></span>
<button id="next" aria-label="Следующий вариант"></button>
</nav>
<script>
const variants = {
A: {
name: "Линейные реплики",
source: `# Транскрипт: пример-встречи.mp4
- **Дата транскрипции**: 2026-08-14 12:00:00
- **Модель**: gigaam-v3-e2e-rnnt
- **Язык**: ru (задан явно)
- **Длительность**: 25:59
- **Устройство**: ONNX (CPU)
- **Диаризация**: 4 голосовых кластера
- **Внимание**: Speaker 4 — малый кластер (00:19; 1,3% речи), возможна ошибка разделения
---
[08:50] Speaker 3: А показываем, получается, в их контуре, не в нашем?
[08:54] Speaker 2: В нашем, по-моему.
[08:55] Speaker 3: В нашем. А, отлично. У нас просто есть тестовый контур, который на самом деле…
[09:07] Speaker 1: Мы же у них не разворачиваемся. Мне гораздо проще накатывать обновления на наш контур.
[11:34] Speaker 2: Результаты проверок пишутся туда же.
[11:39] Speaker 1: По сути, нам нужно переписать только ту часть, которая обрабатывает файл.
[11:43] Speaker 3: Да, а дальше переиспользовать существующую запись результатов.`,
preview: `
<h1>Транскрипт: пример-встречи.mp4</h1>
<ul>
<li><strong>Дата транскрипции</strong>: 2026-08-14 12:00:00</li>
<li><strong>Модель</strong>: gigaam-v3-e2e-rnnt</li>
<li><strong>Язык</strong>: ru (задан явно)</li>
<li><strong>Длительность</strong>: 25:59</li>
<li><strong>Устройство</strong>: ONNX (CPU)</li>
<li><strong>Диаризация</strong>: 4 голосовых кластера</li>
</ul>
<div class="warning"><strong>Внимание:</strong> Speaker 4 — малый кластер (00:19; 1,3% речи), возможна ошибка разделения.</div>
<hr>
<p class="turn"><span class="time">[08:50]</span> Speaker 3: А показываем, получается, в их контуре, не в нашем?</p>
<p class="turn"><span class="time">[08:54]</span> Speaker 2: В нашем, по-моему.</p>
<p class="turn"><span class="time">[08:55]</span> Speaker 3: В нашем. А, отлично. У нас просто есть тестовый контур, который на самом деле…</p>
<p class="turn"><span class="time">[09:07]</span> Speaker 1: Мы же у них не разворачиваемся. Мне гораздо проще накатывать обновления на наш контур.</p>
<p class="turn"><span class="time">[11:34]</span> Speaker 2: Результаты проверок пишутся туда же.</p>
<p class="turn"><span class="time">[11:39]</span> Speaker 1: По сути, нам нужно переписать только ту часть, которая обрабатывает файл.</p>
<p class="turn overlap"><span class="time">[11:43]</span> Speaker 3: Да, а дальше переиспользовать существующую запись результатов.</p>`
},
B: {
name: "Сценарий",
source: `# Транскрипт: пример-встречи.mp4
> Диаризация нашла четыре голосовых кластера. Speaker 4 занимает 19 секунд и может быть ошибкой разделения.
---
### Speaker 3 · [08:50.75 - 08:54.73]
> А показываем, получается, в их контуре, не в нашем?
### Speaker 2 · [08:54.73 - 08:55.91]
> В нашем, по-моему.
### Speaker 3 · [08:55.81 - 09:07.96]
> В нашем. А, отлично. У нас просто есть тестовый контур, который на самом деле…
### Speaker 1 · [09:07.96 - 09:18.80]
> Мы же у них не разворачиваемся. Мне гораздо проще накатывать обновления на наш контур.
## Перекрывающаяся речь · [11:43.57 - 11:44.82]
> **Speaker 1:** По сути, нам нужно переписать только ту часть, которая обрабатывает файл.
>
> **Speaker 3:** Да, а дальше переиспользовать существующую запись результатов.`,
preview: `
<h1>Транскрипт: пример-встречи.mp4</h1>
<blockquote><p>Диаризация нашла четыре голосовых кластера. Speaker 4 занимает 19 секунд и может быть ошибкой разделения.</p></blockquote>
<hr>
<h3>Speaker 3 · <span class="time">[08:50.75 - 08:54.73]</span></h3>
<blockquote><p>А показываем, получается, в их контуре, не в нашем?</p></blockquote>
<h3>Speaker 2 · <span class="time">[08:54.73 - 08:55.91]</span></h3>
<blockquote><p>В нашем, по-моему.</p></blockquote>
<h3>Speaker 3 · <span class="time">[08:55.81 - 09:07.96]</span></h3>
<blockquote><p>В нашем. А, отлично. У нас просто есть тестовый контур, который на самом деле…</p></blockquote>
<h3>Speaker 1 · <span class="time">[09:07.96 - 09:18.80]</span></h3>
<blockquote><p>Мы же у них не разворачиваемся. Мне гораздо проще накатывать обновления на наш контур.</p></blockquote>
<h2>Перекрывающаяся речь · <span class="time">[11:43.57 - 11:44.82]</span></h2>
<blockquote class="overlap"><p><strong>Speaker 1:</strong> По сути, нам нужно переписать только ту часть, которая обрабатывает файл.<br><br><strong>Speaker 3:</strong> Да, а дальше переиспользовать существующую запись результатов.</p></blockquote>`
},
C: {
name: "Таблица событий",
source: `# Транскрипт: пример-встречи.mp4
| Speaker | Речь | Доля | Примечание |
|---|---:|---:|---|
| Speaker 1 | 09:33 | 40,3% | основной кластер |
| Speaker 2 | 08:07 | 34,3% | основной кластер |
| Speaker 3 | 05:42 | 24,1% | основной кластер |
| Speaker 4 | 00:19 | 1,3% | возможная ошибка разделения |
| Начало | Конец | Кто | Текст | Событие |
|---:|---:|---|---|---|
| 08:50.75 | 08:54.73 | S3 | А показываем, получается, в их контуре, не в нашем? | — |
| 08:54.73 | 08:55.91 | S2 | В нашем, по-моему. | — |
| 08:55.81 | 09:07.96 | S3 | В нашем. А, отлично. У нас просто есть тестовый контур… | — |
| 09:07.96 | 09:18.80 | S1 | Мы же у них не разворачиваемся. Мне проще накатывать обновления на наш контур. | — |
| 11:39.91 | 11:44.82 | S1 | Нам нужно переписать только ту часть, которая обрабатывает файл. | overlap:S3 |
| 11:43.57 | 11:46.47 | S3 | Да, а дальше переиспользовать существующую запись результатов. | overlap:S1 |`,
preview: `
<h1>Транскрипт: пример-встречи.mp4</h1>
<table>
<thead><tr><th>Speaker</th><th>Речь</th><th>Доля</th><th>Примечание</th></tr></thead>
<tbody>
<tr><td>Speaker 1</td><td>09:33</td><td>40,3%</td><td>основной кластер</td></tr>
<tr><td>Speaker 2</td><td>08:07</td><td>34,3%</td><td>основной кластер</td></tr>
<tr><td>Speaker 3</td><td>05:42</td><td>24,1%</td><td>основной кластер</td></tr>
<tr class="warning"><td>Speaker 4</td><td>00:19</td><td>1,3%</td><td>возможная ошибка разделения</td></tr>
</tbody>
</table>
<h2>События</h2>
<table>
<thead><tr><th>Начало</th><th>Конец</th><th>Кто</th><th>Текст</th><th>Событие</th></tr></thead>
<tbody>
<tr><td>08:50.75</td><td>08:54.73</td><td>S3</td><td>А показываем, получается, в их контуре, не в нашем?</td><td>—</td></tr>
<tr><td>08:54.73</td><td>08:55.91</td><td>S2</td><td>В нашем, по-моему.</td><td>—</td></tr>
<tr><td>08:55.81</td><td>09:07.96</td><td>S3</td><td>В нашем. А, отлично. У нас просто есть тестовый контур…</td><td>—</td></tr>
<tr><td>09:07.96</td><td>09:18.80</td><td>S1</td><td>Мы же у них не разворачиваемся. Мне проще накатывать обновления на наш контур.</td><td>—</td></tr>
<tr class="overlap"><td>11:39.91</td><td>11:44.82</td><td>S1</td><td>Нам нужно переписать только ту часть, которая обрабатывает файл.</td><td>overlap:S3</td></tr>
<tr class="overlap"><td>11:43.57</td><td>11:46.47</td><td>S3</td><td>Да, а дальше переиспользовать существующую запись результатов.</td><td>overlap:S1</td></tr>
</tbody>
</table>`
}
};
const keys = Object.keys(variants);
const params = new URLSearchParams(window.location.search);
let current = (params.get("variant") || "A").toUpperCase();
if (!variants[current]) current = "A";
function render() {
const variant = variants[current];
document.getElementById("preview").innerHTML = variant.preview;
document.getElementById("source").textContent = variant.source;
document.getElementById("variant-label").textContent = `${current}${variant.name}`;
document.title = `${current}${variant.name} · PROTOTYPE`;
}
function move(offset) {
const index = keys.indexOf(current);
current = keys[(index + offset + keys.length) % keys.length];
const url = new URL(window.location.href);
url.searchParams.set("variant", current);
window.history.replaceState({}, "", url);
render();
}
document.getElementById("previous").addEventListener("click", () => move(-1));
document.getElementById("next").addEventListener("click", () => move(1));
window.addEventListener("keydown", (event) => {
const target = event.target;
if (target.matches("input, textarea, [contenteditable]")) return;
if (event.key === "ArrowLeft") move(-1);
if (event.key === "ArrowRight") move(1);
});
render();
</script>
</body>
</html>
+16
View File
@@ -15,3 +15,19 @@ _Avoid_: Доступная модель, дефолт
**Модель по умолчанию**:
Поддерживаемая модель, которую проект выбирает без явного указания модели пользователем для определённого пути выполнения.
_Avoid_: Рекомендуемая модель, поддерживаемая модель
**Опорная разметка**:
Разметка, принятая за точку отсчёта при измерении чего-то другого. Опорной её делает роль в измерении, а не качество: она не выверена вручную и сама может содержать ошибки, поэтому посчитанная по ней величина осмысленна как порядок, но не как точное значение.
_Avoid_: Эталонная разметка, истинная разметка, ground truth
**Сегмент распознавания**:
Непрерывный временной фрагмент аудио, для которого движок возвращает связный текст с общим контекстом. Может содержать речь нескольких говорящих и не равен реплике говорящего.
_Avoid_: Реплика, фраза говорящего
**Слово с временной привязкой**:
Распознанное слово, положение которого известно на временной шкале записи. Минимальная единица, которой назначается говорящий.
_Avoid_: Токен, ASR-сегмент
**Реплика говорящего**:
Последовательность соседних слов с временной привязкой, назначенных одному говорящему. Это единица структуры готового транскрипта, а не запуска модели распознавания.
_Avoid_: Сегмент распознавания, ASR-сегмент
@@ -193,5 +193,7 @@ Silero VAD, то есть они проходят по тишине. В разг
- Проверить границы диаризации на слух или сверкой с внешним транскриптом:
совпадение числа говорящих не доказывает правильность интервалов.
- Сравнить эмбеддинги, обученные не только на английском, на русской речи.
- Измерить производительность на целевом Intel Core i5 11-го поколения.
- Измерить производительность на доступном слабом 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,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()