diff --git a/CONTEXT.md b/CONTEXT.md index 6891e37..7ac5ae0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -32,6 +32,10 @@ _Avoid_: Токен, ASR-сегмент Упорядоченный набор временных интервалов речи, каждому из которых назначена анонимная метка говорящего. Не содержит распознанного текста, имени участника или голосового эмбеддинга. _Avoid_: Результат диаризации, сегменты говорящих +**Голосовой кластер**: +Анонимная группа интервалов разметки говорящих, которые диаризатор относит к одному голосу. Не равен подтверждённому участнику встречи: ложный или малый кластер сохраняет отдельную метку. +_Avoid_: Участник, человек + **Реплика говорящего**: Последовательность соседних слов с временной привязкой, назначенных одному говорящему. Это единица структуры готового транскрипта, а не запуска модели распознавания. _Avoid_: Сегмент распознавания, ASR-сегмент diff --git a/docs/adr/007-word-level-speaker-diarization.md b/docs/adr/007-word-level-speaker-diarization.md new file mode 100644 index 0000000..0d94fef --- /dev/null +++ b/docs/adr/007-word-level-speaker-diarization.md @@ -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 | diff --git a/docs/backlog.md b/docs/backlog.md index 60dd751..ed6450b 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -179,75 +179,6 @@ 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 с на реплику). Вынести в опцию/конфиг или уменьшить -дефолт. - -**Почему откладывается:** при диаризации абзацы будут ломаться по смене -спикера естественно — сначала решить с диаризацией, чтобы не делать -ручку, которая устареет. - ---- - ### Словарь замен технических терминов — запасной план **Что:** Пост-обработка текста сегментов словарём замен по границам слов diff --git a/docs/specs/2026-08-14-speaker-diarization.md b/docs/specs/2026-08-14-speaker-diarization.md new file mode 100644 index 0000000..3171d99 --- /dev/null +++ b/docs/specs/2026-08-14-speaker-diarization.md @@ -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).