Локальный ассистент для зумов, часть 3: кто это говорит — и почему я трижды ошибся, отвечая на этот вопрос. onnx.. onnx. Open source.. onnx. Open source. sherpa-onnx.. onnx. Open source. sherpa-onnx. speech-to-text.. onnx. Open source. sherpa-onnx. speech-to-text. ассистент встреч.. onnx. Open source. sherpa-onnx. speech-to-text. ассистент встреч. бенчмарки.. onnx. Open source. sherpa-onnx. speech-to-text. ассистент встреч. бенчмарки. диаризация.. onnx. Open source. sherpa-onnx. speech-to-text. ассистент встреч. бенчмарки. диаризация. Звук.. onnx. Open source. sherpa-onnx. speech-to-text. ассистент встреч. бенчмарки. диаризация. Звук. искусственный интеллект.. onnx. Open source. sherpa-onnx. speech-to-text. ассистент встреч. бенчмарки. диаризация. Звук. искусственный интеллект. локальные llm.. onnx. Open source. sherpa-onnx. speech-to-text. ассистент встреч. бенчмарки. диаризация. Звук. искусственный интеллект. локальные llm. Мессенджеры.. onnx. Open source. sherpa-onnx. speech-to-text. ассистент встреч. бенчмарки. диаризация. Звук. искусственный интеллект. локальные llm. Мессенджеры. методика замеров.. onnx. Open source. sherpa-onnx. speech-to-text. ассистент встреч. бенчмарки. диаризация. Звук. искусственный интеллект. локальные llm. Мессенджеры. методика замеров. Ненормальное программирование.. onnx. Open source. sherpa-onnx. speech-to-text. ассистент встреч. бенчмарки. диаризация. Звук. искусственный интеллект. локальные llm. Мессенджеры. методика замеров. Ненормальное программирование. приватность.. onnx. Open source. sherpa-onnx. speech-to-text. ассистент встреч. бенчмарки. диаризация. Звук. искусственный интеллект. локальные llm. Мессенджеры. методика замеров. Ненормальное программирование. приватность. эмбеддинги диктора.

В конце второй части я пообещал честный замер новых локальных моделей распознавания. Обещание пришлось выполнять целый день, и результат оказался самым обидным из возможных: менять нечего. Ни модель, ни эмбеддер, ни алгоритм. А три дефекта, которые я за этот день торжественно нашёл, оказались дефектами не системы, а моего измерительного стенда.

Эта часть — про диаризацию: как ассистент отвечает на вопрос «кто это сказал» и почему на рабочем созвоне этот вопрос сложнее, чем разобрать слова. Предыдущие части читать не обязательно: первая — про то, как всё записывается и распознаётся локально, вторая — про граф встреч, который из этого вырастает. Здесь я держу отдельный сюжет.

Главный вывод дня даже не про голоса. Он такой: прежде чем сравнивать что-то с чем-то, убедись, что мерится тот самый код, который работает в бою, и что эталон снят с того самого материала, по которому идёт прогон. Звучит как трюизм из методички. Стоило дня работы.

Что вообще происходит на созвоне

Напомню устройство в двух абзацах. Мак пишет два потока: системный звук (то, что приходит из ВКС в динамики) и микрофон. Дальше распознавание речи, дальше диаризация — разбиение записи на куски по говорящим, дальше склейка в стенограмму и запись встречи в граф в Obsidian. Всё локально, на M1 Max, ничего не улетает в облако.

Диаризация у меня собрана из двух кусочков в sherpa-onnx: сегментация pyannote 3.0 (шесть мегабайт, размечает, где вообще есть речь и где границы) и эмбеддер ERes2Net, который на каждый кусок речи выдаёт вектор на 512 чисел — «отпечаток голоса». Потом кластеризация: близкие векторы — один человек.

Теперь то, чего в статьях про диаризацию обычно не пишут, потому что там мерят подкасты и телефонные корпуса. На рабочем созвоне запись выглядит так:

  • четверо-пятеро участников приходят одним смешанным потоком из ВКС, часть — через ноутбучный микрофон в трёх метрах от говорящего, часть — через гарнитуру, часть — с телефона в машине;

  • поток уже прошёл через кодек с подавлением шума, который сам по себе жуёт как раз те высокие частоты, на которых эмбеддеры различают людей;

  • люди перебивают друг друга, поддакивают, кашляют и говорят одновременно;

  • а самое интересное — в системном канале нет меня самого. ВКС не возвращает участнику его собственный голос. Мой голос есть только в микрофоне — вместе с эхом чужих голосов из динамиков.

Последний пункт я знал головой. Но не держал в руках, когда садился мерить. За это и заплатил.

Эталон, без которого всё враньё

Сравнивать диаризаторы «на глазок» нельзя: смотришь на стенограмму, видишь, что вроде похоже на правду, и радуешься. Нужен эталон — файл, где для каждого куска записи руками проставлено, кто говорит.

Взял 20-минутный фрагмент рабочей встречи, прогнал сегментацию, получил 55 кусков речи с временами и сел слушать. Каждый кусок, руками, с указанием имени. Часть кусков осталась под вопросом — там, где говорят двое сразу, или где реплика длиной в «угу». В итоге размечено 55% времени; это нормально для первого прохода, но цифры ниже надо читать с поправкой: порядок величин верный, точные проценты сдвинутся после полной разметки.

Метрика простая и не совсем стандартная: беру каждого человека из эталона и смотрю, какая доля его речи (в секундах, не в кусках) попала в его главную метку. Плюс два условия, которые должны выполняться всегда: нет метки, под которой сидят двое разных людей, и нет человека, размазанного по нескольким меткам.

Классический DER я сознательно не считал. Он смешивает в одно число три разные беды: пропущенную речь, ложную речь и путаницу говорящих. Мне нужна была именно последняя.

И сразу отвечу на вопрос, который иначе появится в комментариях первым: да, всё дальнейшее — один двадцатиминутный фрагмент одной встречи. Это не бенчмарк и не претендует им быть. Мне надо было принять решение про свой продукт на своём материале, а не выпустить статью с таблицей лидеров. Все выводы читайте с этой поправкой; воспроизводить — на своих записях.

Диагноз первый: «оно склеивает меня с коллегой»

Первый прогон дал картину катастрофы: мой голос размазан по трём меткам, под одной меткой сидят четверо. Я честно записал это в вердикт, потом с тем же результатом прогнал две внешние модели и записал ещё дважды.

А потом посмотрел на эталон. Эталон был собран по сведённой стенограмме — той, где уже склеены микрофон и системный канал. То есть в эталоне я есть. А прогон шёл по одной дорожке системного звука, где меня физически нет.

Модель не может склеить меня с коллегой, если меня нет в файле. Она получала штраф за реплики, которых в её входных данных не существовало.

Это не тонкость. Это грубейшая ошибка, и весь день дальше — расплата за неё. Три вердикта пришлось выбросить, а два из них были про чужие модели, которым я на ровном месте занизил оценку.

Диагноз второй: «виноваты эмбеддинги»

Дальше я запустил прогон с заранее заданным числом кластеров — «оракульный» режим, когда системе говорят: тут ровно пять человек, разложи. Логика такая: если при известном числе людей всё равно путает, дело не в кластеризации, а в самих векторах. Путало. Значит эмбеддер плохо различает голоса на моём канале, надо искать новый.

Полез мерить расстояния между векторами напрямую. Первая версия замера показала, что векторы разных людей похожи, и я почти поверил. Потом посмотрел, откуда беру векторы: с минутных окон. А на минутном окне рабочего созвона говорят трое. Я сравнивал не голоса людей, а «средние по палате» — смеси, которые похожи по построению.

Переписал на настоящие сегменты речи. Картина перевернулась: метки чистые на 97–100%, эмбеддер людей различает прекрасно.

Диагноз третий: «дробит одного человека на восемнадцать меток»

Вечером я решил проверить сырой выход кластеризатора и вызвал sherpa напрямую. Восемнадцать меток на пятерых. Ну вот же, думаю, оно.

Оно, да не то. В моём diarize.py с конца июля стоит второй проход: осколки кластеров склеиваются по средним эмбеддингам через union-find, тихие сегменты добираются к ближайшему по вектору, «слепые» уходят в отдельный пул. Вызвав sherpa напрямую, я аккуратно обошёл собственный код и получил ровно то, что этот код и чинит.

Восемнадцать меток — это то, что кластеризатор отдаёт до склейки. Боевой прогон на том же фрагменте даёт восемь. В стенограмме — восемь.

Три диагноза за день, и у всех трёх одна и та же причина: измерялось не то, что работает, и сравнивалось не с тем, что размечено.

А теперь настоящие цифры

Штатный путь, боевые настройки, тот же двадцатиминутный фрагмент, размеченный эталон:

473 с на 20 минут записи (2,5× быстрее реального времени)
80 сегментов речи, 8 меток на выходе
участник А   4,0 мин   главная метка 97%
участник Б   3,8 мин   главная метка 89%
верно приписано 7,3 из 8,2 мин = 88%, ошибка 12%
меток, под которыми двое: 0
людей, размазанных по меткам: 0

Двенадцать процентов ошибки атрибуции. Каждый участник — в одной метке. Ни одна метка не делится между людьми.

Сразу оговорюсь, чтобы не вводить никого в заблуждение: с DER из статей эту цифру сравнивать нельзя. DER складывает пропущенную речь, ложную речь и путаницу говорящих, а у меня посчитана только путаница — заведомо меньшая беда, да ещё и на двадцати минутах одной встречи, размеченных наполовину. Числа из чужих таблиц (pyannote community-1 на AMI даёт DER 17%, и это переговорка, где у каждого своя гарнитура) я привожу как ощущение масштаба задачи, а не как строку в турнирной таблице, где я кого-то обошёл.

Важнее другое: систему, которую я весь день собирался чинить, чинить не надо.

A/B эмбеддеров: почему бенчмарк не помог

Раз уж стенд был собран, я прогнал все четыре эмбеддера из релиза sherpa-onnx на одном и том же фрагменте, с одним и тем же порогом кластеризации. EER в таблице — с VoxCeleb1-O, стандартного бенчмарка верификации диктора, в процентах; чем меньше, тем лучше. «Меток» — сырой выход кластеризатора, до второго прохода.

эмбеддер

EER Vox1-O, %

меток (сырой выход)

скорость

мой ERes2Net-base 200k zh-cn, 39,6 МБ

0,84

18

2,7×

CAM++ zh_en advanced, 28,3 МБ

0,65

24

6,2×

ERes2NetV2, 71,4 МБ

0,61

20

1,2×

WeSpeaker ResNet293-LM, 114,3 МБ

0,447

3

0,4×

Читать эту таблицу надо снизу вверх и с некоторым злорадством. Лучшая по бенчмарку модель — вдвое лучше моей по EER — на моей записи слила пятерых человек в три метки и работала в семь раз медленнее, то есть медленнее реального времени. Худшая по бенчмарку дала самый вменяемый сырой выход.

Антикорреляция, конечно, случайная: четыре точки — не выборка. Но она хорошо показывает, чего бенчмарк не измеряет. VoxCeleb — это чистая речь из интервью на YouTube, задача «два отрезка — один человек или разные». У меня — кодек ВКС, дальний микрофон, перекрытия и задача кластеризации без известного числа людей. Разные вселенные.

И финальный гвоздь: после второго прохода все четыре эмбеддера сходятся к семи меткам и 11–12% ошибки. Разброс сырых меток от 3 до 24 — а на выходе одно и то же.

Тут важно не перепродать вывод. Это не значит «модель эмбеддера не имеет значения»: порог кластеризации у меня один на всех, откалиброван когда-то под ERes2Net, и в чужом пространстве векторов он ведёт себя иначе — что и видно по разбросу сырых меток. Честная формулировка такая: при моём пороге второй проход съедает разницу между эмбеддерами. Чтобы утверждать больше, надо подбирать порог отдельно под каждую модель, а это уже другой эксперимент, и ради него я не буду менять то, что работает.

Заодно закрыл вопрос с ReDimNet2, вокруг которого ходил три дня. Модель отличная (EER 0,29 у B6, MIT-лицензия, есть даже готовый ONNX от энтузиаста), но в sherpa-onnx она не встаёт: движок диспетчеризует ровно по трём типам моделей — wespeaker, 3d-speaker, nemo, а у ReDimNet2 ещё и мел-спектрограмма считается вне графа. Это не «положить другой файл», это правка C++ и свой фронтенд.

Отдельно, для тех, кто будет выбирать эмбеддер под русскую речь: публичных цифр на русском нет. Я искал специально. Ни для ERes2Net, ни для CAM++, ни для ReDimNet, ни для TitaNet. Единственная близкая работа — Ferro Filho et al. с Interspeech 2025 про устойчивость эмбеддингов к смене домена, частоты дискретизации и кодека; вывод там качественный: деградируют все, ReDimNet меньше остальных, низкий битрейт бьёт больно. Так что мерить придётся самому, на своих записях. Что, если подумать, и есть мораль этой статьи.

Внешние модели: спасибо, не надо

Три кандидата, тот же фрагмент.

система

что вышло

скорость

моя связка

12% ошибки, каждый в одной метке

2,5×

VibeVoice-ASR 9B, офлайн

7 меток, четверо под одной

1,2×

VibeVoice-ASR Streaming 7B

4 метки, четверо под одной

3,9×

MOSS-Transcribe

19 меток, обрыв на 17-й минуте

10–15×

MOSS я мельком поминал в конце прошлой части — быстрый до неприличия, но на двадцати минутах рассыпается в девятнадцать говорящих. С тех пор ничего не изменилось.

По VibeVoice любопытная деталь. Сначала я померил стриминговую версию 7B и получил потолок в четыре метки — она нумерует говорящих заново на каждом чанке в 2,9 секунды. Потом обнаружил, что мерил периферию: у стриминговой на Hugging Face две тысячи загрузок, у базовой офлайн-модели 9B — семьсот тысяч. Померил базовую: потолок снялся, метки держатся сквозь всю запись, русский текст заметно лучше — числа и аббревиатуры распознаёт верно, что для рабочей встречи важнее красивых запятых. Но четверо участников по-прежнему делят одну метку с долями 38–55%, а скорость — 1,2×, то есть на часовой встрече это пятьдесят минут работы.

Проверил заодно гипотезу дрейфа: вдруг человек в начале записи получает одну метку, а в конце другую. Разбил сверку по половинам — в обеих те же люди делят те же метки. Склейка настоящая, а не потеря идентичности со временем.

Запуск обеих моделей на MLX потребовал двух правок в конфиге, оставлю здесь на случай, если кто-то будет повторять: нужен алиас text_config = decoder_config в config.json, иначе загрузчик не находит конфигурацию декодера, и нужен jinja2 — офлайн-модель применяет chat template, стриминговая нет.

Оговорка про честность: цифры чужих моделей считались против того самого негодного эталона из первого диагноза, поэтому их размазывание завышено. Пересчитывать не стал — вывод «не встраиваем» держится по остальным осям: скорость, качество русского, лицензия. Но если вы будете выбирать между ними для своей задачи, мои проценты не берите, померьте сами.

Что действительно сломалось

Пока я гонялся за призраками, нашлось кое-что настоящее — и совсем не там, где искал.

Первый: захват падает молча. Я убрал из системы виртуальное аудиоустройство BlackHole — теперь системный звук берётся через ScreenCaptureKit, штатным способом Apple. Но запас прочности стал одинарным: если ScreenCaptureKit не отдал поток, запись продолжается с одного микрофона. Встреча пишется. Файл растёт. Собеседников в нём нет. Узнаёшь об этом через час, когда стенограмма приходит односторонняя.

Теперь на этот случай приложение орёт тремя способами сразу: полоса в интерфейсе, системное уведомление и строка в логе с причиной и хвостом ошибки. Правило, которое я вывел для себя: если деградация не видна на слух и не видна глазами — она обязана кричать.

Это, кстати, тот же урок, что и правило ночного прогона из прошлой части: «Ok» пишется только тогда, когда работа сделана. Ночь, в которой ни один файл не обновился, зелёной быть не имеет права. Запись, в которой нет собеседников, — тоже.

А вот второе оказалось не дефектом — и это тоже результат. В микрофоне мой голос идёт вместе с эхом из динамиков, и склейка отбрасывает микрофонный кусок, если он больше чем наполовину перекрыт речью системного канала. Логика правильная, но я боялся, что вместе с эхом улетают мои поддакивания и перебивания — а на рабочей встрече половина реплик именно такие.

Прежде чем чинить, померил. Прогнал распознавание по выброшенным кускам и сравнил слова с системным каналом за то же время: совпало — эхо, выброшено верно; не совпало — потерянная реплика. Проверил двадцать пять самых длинных выброшенных кусков (короче секунды текста не даёт, а длинные — как раз то, чего было бы жалко). Эхом оказались все двадцать пять. Потерь речи — ноль. Гипотеза не подтвердилась, править нечего.

Заодно проверил, нельзя ли вычесть эхо по-честному, адаптивным фильтром. Нельзя. Схема замера обычная: сначала ищем задержку «динамик → комната → микрофон» по максимуму взаимной корреляции в окне до 200 мс, потом сдвигаем опорный сигнал на найденный лаг и гоняем NLMS, меряя ERLE — на сколько децибел упала энергия остатка.

Три тридцатисекундных окна из середины встречи, микрофон −33 дБ, системный канал −21…−25 дБ:

окно 11:00   пик корреляции 0,027 на 145,8 мс   ERLE −1,4 дБ (512 отводов)
окно 15:00   пик корреляции 0,044 на   4,9 мс   ERLE +0,2 дБ
окно 20:00   пик корреляции 0,037 на  47,9 мс   ERLE −1,8 дБ
окно 11:00, 4096 отводов                        ERLE −0,5 дБ

Корреляция 0,03–0,04 — это уже максимум по всем задержкам, а не «мы забыли выровнять дорожки». И сама задержка пика скачет от окна к окну на полтораста миллисекунд, то есть устойчивого акустического лага там нет вовсе: это шум, а не эхо. ERLE в районе нуля и ниже означает, что фильтр не убирает энергию, а добавляет. Причина понятная: путь через комнату нелинейный, плюс ВКС на той стороне сам подмешивает своё подавление, и микрофон слышит уже обработанный сигнал, а не то, что было в опорном канале.

Первая версия фильтра вообще взорвалась — ERLE −33 дБ. Фиксированная регуляризация в знаменателе разгоняла коэффициенты на паузах, когда опорный сигнал почти нулевой. Привязал её к мощности опорного сигнала, и фильтр перестал сходить с ума — но лучше нуля так и не стал.

Итог по этому куску такой: тупое отсечение по перекрытию побеждает умное вычитание, потому что умное вычитание построено на предположении о линейности, а линейности здесь нет.

Чек-лист, который я теперь держу перед носом

Три ложных диагноза и одна гипотеза, снятая замером до того, как я полез чинить. Ноль строк исправленного кода в самой диаризации. Вот что я из этого записал в правила проекта:

  1. Этот прогон вообще идёт через боевой код? Не через библиотеку напрямую, не через «упрощённый вариант для замера», а через ту функцию, которую вызывает продукт. Если для замера пришлось написать свой вызов — это уже другая система.

  2. Эталон снят с того же материала? Не с соседней дорожки, не со сведённого результата, не с прошлой версии файла.

  3. Единица сравнения — та же? Минутные окна против посегментных векторов дали противоположные выводы на одних и тех же данных.

  4. Померил ли я до того, как чинить? Единственный настоящий дефект дня вылез из замера, а не из чтения кода. И одна убедительная «поломка» замером снялась — я собирался переписывать логику, которая работает правильно.

  5. Бенчмарк с чужого домена — не аргумент. Он ранжирует модели на своей задаче, а не на моей.

Первый пункт я вбил прямо в инструменты: файлы с историей провалов у меня помечены, и правка такого файла печатает урок с датой. Теперь это срабатывает и на чтение — потому что три ложных диагноза выросли не из правок, а из чтения кода и построения гипотез по прочитанному. Урок нужен до того, как начнёшь думать, а не после того, как полезешь чинить.

Что дальше

Слепок голоса владельца — чтобы «где я» определялось по эталону голоса, а не по каналу. Это единственный подход, который снимает проблему первого диагноза не в измерениях, а по-настоящему: сейчас моя идентичность держится на том, что микрофон — это я, а системный канал — не я. Работает, пока встреча удалённая. На очной встрече вокруг одного микрофона это рассыпается.

Дальше — дозаметить эталон целиком, чтобы 12% стали настоящими 12%, и повторить прогон на очной встрече, где физика совсем другая.

А из чужих моделей я пока не вижу той, ради которой стоит менять связку. GigaAM на распознавании, sherpa-onnx на диаризации, второй проход своей склейки — и 12% ошибки при скорости 2,5× от реального времени на ноутбуке.

Код: github.com/charoiteai/Charoite_audio, Apache 2.0. Первая часть — про устройство и запись, вторая — про граф встреч как память.

Автор: Charoiteai

Источник