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

Merged
ddmitry merged 15 commits from feature/8-diarization-map into master 2026-08-14 17:19:15 +03:00
4 changed files with 302 additions and 69 deletions
Showing only changes of commit b8333ba583 - Show all commits
+4
View File
@@ -32,6 +32,10 @@ _Avoid_: Токен, ASR-сегмент
Упорядоченный набор временных интервалов речи, каждому из которых назначена анонимная метка говорящего. Не содержит распознанного текста, имени участника или голосового эмбеддинга. Упорядоченный набор временных интервалов речи, каждому из которых назначена анонимная метка говорящего. Не содержит распознанного текста, имени участника или голосового эмбеддинга.
_Avoid_: Результат диаризации, сегменты говорящих _Avoid_: Результат диаризации, сегменты говорящих
**Голосовой кластер**:
Анонимная группа интервалов разметки говорящих, которые диаризатор относит к одному голосу. Не равен подтверждённому участнику встречи: ложный или малый кластер сохраняет отдельную метку.
_Avoid_: Участник, человек
**Реплика говорящего**: **Реплика говорящего**:
Последовательность соседних слов с временной привязкой, назначенных одному говорящему. Это единица структуры готового транскрипта, а не запуска модели распознавания. Последовательность соседних слов с временной привязкой, назначенных одному говорящему. Это единица структуры готового транскрипта, а не запуска модели распознавания.
_Avoid_: Сегмент распознавания, ASR-сегмент _Avoid_: Сегмент распознавания, ASR-сегмент
@@ -0,0 +1,97 @@
# 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 секунды; поэтому малые кластеры нельзя молча отбрасывать, а разметку нельзя
считать эталоном точных границ и перекрывающейся речи.
На доступном слабом 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 и диаризации независимо получают одно аудио, после
чего отдельная операция сводит слова с интервалами разметки говорящих и
объединяет соседние слова одного говорящего в реплики. Распознавание по-прежнему
выполняется на полных сегментах и сохраняет контекст модели.
Первая реализация последовательна на всех устройствах: ASR, диаризация,
сведение, Markdown. В батче один диаризатор создаётся после prescan,
переиспользуется для всех файлов и освобождается вместе с командой. Разметка
говорящих живёт только в памяти текущего запуска; постоянного кеша результата
нет.
Грубого 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 |
-69
View File
@@ -179,75 +179,6 @@ openvino-cpu, запись 25:59) с облачным сервисом Hypescrib
LLM для чистки текста, сопоставление Speaker N с именами — это работа 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 с на реплику). Вынести в опцию/конфиг или уменьшить
дефолт.
**Почему откладывается:** при диаризации абзацы будут ломаться по смене
спикера естественно — сначала решить с диаризацией, чтобы не делать
ручку, которая устареет.
---
### Словарь замен технических терминов — запасной план ### Словарь замен технических терминов — запасной план
**Что:** Пост-обработка текста сегментов словарём замен по границам слов **Что:** Пост-обработка текста сегментов словарём замен по границам слов
@@ -0,0 +1,201 @@
# Диаризация говорящих в транскрипте
## Проблема
Текущий транскрипт знает только сегменты распознавания. Их границы проходят по
тишине и не совпадают со сменой говорящего, поэтому один сегмент может содержать
несколько реплик. Назначение одной метки всему сегменту искажает структуру
диалога и делает транскрипт слабым сырьём для конспекта или протокола встречи.
[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-пути обязаны предоставлять пословный контракт до включения
диаризации:
| Путь | Источник временных привязок |
|---|---|
| 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;
- `FastClusteringConfig.threshold = 0.89`;
- автоматическое число кластеров.
`--speakers N` передаёт явное число кластеров вместо автоматического. Малый
кластер — кластер с речью короче максимума из 5 секунд и 2% длительности записи.
Он не отбрасывается и получает обычный номер, но вызывает предупреждение.
## Формат Markdown
При двух и более найденных кластерах тело состоит из линейных реплик:
```markdown
[09:07] Speaker 1: Мы же у них не разворачиваемся…
[09:18] Speaker 2: Мне гораздо проще накатывать обновления…
```
- Печатается только начало реплики, округлённое до целой секунды. Для записей
длиннее часа используется `[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 ?`, паузы 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).