Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
352296b84a |
@@ -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>
|
||||
@@ -28,14 +28,6 @@ _Avoid_: Реплика, фраза говорящего
|
||||
Распознанное слово, положение которого известно на временной шкале записи. Минимальная единица, которой назначается говорящий.
|
||||
_Avoid_: Токен, ASR-сегмент
|
||||
|
||||
**Разметка говорящих**:
|
||||
Упорядоченный набор временных интервалов речи, каждому из которых назначена анонимная метка говорящего. Не содержит распознанного текста, имени участника или голосового эмбеддинга.
|
||||
_Avoid_: Результат диаризации, сегменты говорящих
|
||||
|
||||
**Голосовой кластер**:
|
||||
Анонимная группа интервалов разметки говорящих, которые диаризатор относит к одному голосу. Не обязательно соответствует реальному участнику встречи: диаризация может создать ложный или малый кластер.
|
||||
_Avoid_: Участник, человек
|
||||
|
||||
**Реплика говорящего**:
|
||||
Последовательность соседних слов с временной привязкой, назначенных одному говорящему. Это единица структуры готового транскрипта, а не запуска модели распознавания.
|
||||
_Avoid_: Сегмент распознавания, ASR-сегмент
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
# 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 |
|
||||
@@ -179,6 +179,75 @@ openvino-cpu, запись 25:59) с облачным сервисом Hypescrib
|
||||
LLM для чистки текста, сопоставление Speaker N с именами — это работа
|
||||
поверх готового транскрипта.
|
||||
|
||||
### Диаризация — разделение говорящих
|
||||
|
||||
**Что:** Опциональный пост-процессинг (не четвёртый бэкенд): диаризация
|
||||
даёт интервалы «кто когда говорил», результат сводится с сегментами ASR,
|
||||
formatter ломает абзац на смене спикера и подписывает `Speaker 1:`.
|
||||
Ставится как extra: `uv sync --extra diarization`.
|
||||
|
||||
**Почему:** Без спикеров MoM не собрать — это ключевой разрыв с облаком
|
||||
по внешнему ревью, и никакое качество распознавания его не компенсирует.
|
||||
Заодно естественно решает «разбивку на реплики» (приоритет №4).
|
||||
|
||||
**Промежуточный статус 2026-08-12:** проведена разведка, описанная в
|
||||
[разведочном замере диаризации](benchmarks/2026-08-12-diarization-feasibility.md).
|
||||
Она закрыла вопрос о движке и открыла более важный вопрос о единице привязки.
|
||||
|
||||
**Движок — вопрос практически закрыт.** `sherpa-onnx` ставится на Windows с
|
||||
Python 3.13, содержит готовый `OfflineSpeakerDiarization`, не тянет torch и не
|
||||
требует токена Hugging Face; модели сегментации и эмбеддингов весят около 33 МБ.
|
||||
Скорость — 11,1× RTFx, то есть примерно полторы длительности ASR. Вариант
|
||||
`pyannote.audio` остаётся отклонённым по прежней причине: torch и HF-токен с
|
||||
принятием лицензии. Отдельный ADR имеет смысл заводить вместе с решением о
|
||||
единице привязки, а не только про движок.
|
||||
|
||||
Замечание для будущих заходов: обе ML-части диаризации уже лежат в
|
||||
`onnx-asr` 0.12 — `PyAnnoteVad` содержит полную локальную сегментацию pyannote
|
||||
(powerset на трёх спикеров, склейка окон), а `WespeakerEmbeddings` даёт
|
||||
эмбеддинги. Публичный API схлопывает сегментацию до речь/не-речь, `load_se` не
|
||||
экспортирован, кластеризации нет. Собирать диаризацию самим на этих деталях —
|
||||
экономия 33 МБ ценой опоры на приватный API; при разведке этот путь не
|
||||
выбирался.
|
||||
|
||||
**Единица привязки — настоящая развилка, решения нет.** Схема «мажоритарный
|
||||
спикер на весь ASR-сегмент», записанная здесь раньше, замером не подтвердилась:
|
||||
27% сегментов содержат не менее секунды чужой речи, и на них приходится больше
|
||||
половины времени транскрипта. Причина — границы сегментов идут по тишине
|
||||
(Silero VAD), а в ВКС собеседники отвечают встык. Варианты:
|
||||
|
||||
- **пословная привязка** — `onnx-asr` отдаёт потокенные таймкоды
|
||||
(`TimestampedResult`), сегмент режется на границе токена при смене
|
||||
говорящего; ASR по-прежнему видит длинное аудио, контекст RNN-T и пунктуация
|
||||
не страдают. Недоступно на OpenVINO GenAI — там потокенных таймкодов нет;
|
||||
- **диаризация первым проходом**, ASR по интервалам говорящего — чистота
|
||||
гарантирована, но короткие куски лишают RNN-T контекста и портят пунктуацию;
|
||||
- **привязка к сегменту с честной пометкой** — оставить огрубление, но считать
|
||||
чистоту и предупреждать в шапке, как уже делается для повторов и потери
|
||||
хвоста.
|
||||
|
||||
**Уточнить перед запуском:** воспроизводится ли доля 27% на других записях,
|
||||
включая разговор на двоих; правильность границ диаризации на слух, а не только
|
||||
совпадение числа говорящих; калибровка порога кластеризации (на пороге из
|
||||
примеров получилось 29 спикеров вместо трёх); эмбеддинги, обученные не только
|
||||
на английском; производительность на целевом Intel Core i5.
|
||||
|
||||
---
|
||||
|
||||
### Ручка нарезки абзацев в formatter
|
||||
|
||||
**Что:** «Минутные простыни» в транскрипте — не свойство модели, а наши
|
||||
константы группировки `_PAUSE_THRESHOLD_S = 2.0` / `_MAX_PARAGRAPH_S =
|
||||
60.0` в `formatter.py` (сырых сегментов много: 23-минутная запись — 360
|
||||
сегментов, ~4 с на реплику). Вынести в опцию/конфиг или уменьшить
|
||||
дефолт.
|
||||
|
||||
**Почему откладывается:** при диаризации абзацы будут ломаться по смене
|
||||
спикера естественно — сначала решить с диаризацией, чтобы не делать
|
||||
ручку, которая устареет.
|
||||
|
||||
---
|
||||
|
||||
### Словарь замен технических терминов — запасной план
|
||||
|
||||
**Что:** Пост-обработка текста сегментов словарём замен по границам слов
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
# Диаризация говорящих в транскрипте
|
||||
|
||||
## Проблема
|
||||
|
||||
Текущий транскрипт знает только сегменты распознавания. Их границы проходят по
|
||||
тишине и не совпадают со сменой говорящего, поэтому один сегмент может содержать
|
||||
несколько реплик. Назначение одной метки всему сегменту искажает структуру
|
||||
диалога и делает транскрипт слабым сырьём для конспекта или протокола встречи.
|
||||
|
||||
[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).
|
||||
Reference in New Issue
Block a user