Есть задачи, которые звучат просто, пока не начинаешь писать под них код. «Определить, кто говорит в аудио» — ровно такая.
Кажется, что в 2026 году это должно работать «из коробки»: взял готовую модель диаризации, взял ASR (распознавание речи), склеил результаты по таймкодам — и катись в продакшен. На практике мы закопались в этой задаче на полтора месяца, перепробовали пять математически выверенных эвристик склейки, сожгли сотни часов работы GPU и в итоге поняли: вся наша архитектура изначально была построена не с той стороны.
Вот вам честная история разработки без причёсывания. С кодом, конкретными таймингами, разбором наших глупых ошибок и тем самым моментом, когда пришлось признать поражение и переписать всё с нуля.
В чём боль: когда автоматизация превращается в ручную работу
Представьте: пользователь заходит на сайт, загружает двухминутное аудио совещания, чтобы быстро получить протокол встречи. Он ждет красивый караоке-плеер, где реплики четко разделены по ролям: «Спикер 1», «Спикер 2». Вместо этого он видит, как важный вопрос одного человека приписывается другому, а короткие реплики согласия («Да», «Согласен») улетают к третьему. > [!IMPORTANT] > Философский конфликт здесь глубже, чем просто баг в UI. > ИИ создается для того, чтобы экономить время. Но если пользователю приходится сидеть в наушниках и вручную переставлять теги спикеров, выверяя каждую секунду, то это не автоматизация. Это дополнительная неоплачиваемая работа стенографистом, которая вызывает только глухое раздражение. Под капотом TEOM OS в этот момент крутились три вещи:- GigaAM v3 — ASR-модель от Сбера. Распознает текст и отдает
words_confidence— массив слов с точными таймкодами (начало и конец звучания каждого слова). - Pyannote — библиотека для VAD (Voice Activity Detection) и диаризации (
speaker-diarization-community-1). Она находит границы реплик и метит их спикерами. - Rust-бэкенд на Axum для дирижирования запросами и фронтенд на Next.js, который рисует караоке.
Audio ──> ASR (куски по ~20с) ──> word timestamps ──┐
├──> merge_speakers() ──> Результат
Audio ──> Diarization (Pyannote) ──> спикеры ───────┘
`
ASR режет аудио на 20-секундные куски, переводит в текст, а функция merge_speakers на бэкенде пытается сопоставить слова из ASR с временными отрезками спикеров от Pyannote.
Звучит логично? Абсолютно. Работает? Нет.
Проблема №1: Лишний запрос и двойная нагрузка на GPU
Сначала мы совершили классическую ошибку оптимизации. Чтобы текст на экране появлялся моментально, мы разделили транскрипцию на два шага:
Быстрый запросuploadвозвращает сырой текст без спикеров (диаризация отключена).Фронтенд запускает фоновый запросfetchDiarization()на эндпоинт/api/demo/diarizeдля разметки спикеров.
Что пошло не так:
* Двойной удар по ресурсам: Мы отправляли один и тот же тяжелый файл на сервер дважды, удваивая нагрузку на GPU.
* Специфические падения: Запрос падал с ошибкой 500 на некоторых аудиоформатах, потому что на фронтенде MIME-тип принудительно хардкодился как audio/mp4 независимо от того, загрузил ли пользователь MP3, WAV или FLAC.
* Перезапись данных: Если фоновый запрос все же отрабатывал, он перезаписывал уже выведенные на экран точные сегменты распознавания своими, часто ломая разметку.
Решение: Мы убрали фоновый fetchDiarization(). Теперь диаризация запрашивается прямо во время первой загрузки (diarizer: "pyannote"). Но настоящий кошмар ждал нас внутри функции склейки.
Проблема №2: Слова «врут» о времени своего произнесения
Логика merge_speakers была проста: берем среднюю точку времени звучания слова (mid = (word.start + word.end) / 2) и проверяем, в какой интервал Pyannote попадает эта точка.
Но мы столкнулись с феноменом: таймкоды слов от GigaAM начинают плыть в зависимости от того, как файл был обработан перед отправкой на сервер.
Для чистого исходного файла test_2min.mp3 таймкоды были идеальными. Но на фронтенде перед отправкой каждый файл проходил через скрипт processMediaFile (ffmpeg.wasm/MediaBunny), который пережимал аудио. Из-за изменения битрейта и перекодирования таймкоды слов смещались на 1–2 секунды вперед или назад.
Вот конкретный кейс, на котором мы споткнулись:
`
ASR-сегмент: [81.83с – 91.66с] (25 слов, распознаны идеально)
Pyannote границы: SPEAKER_00 [85.05с – 86.8с], SPEAKER_01 [86.9с – 106.39с]
Из-за сдвига таймкодов ASR все 25 слов получили midpoint > 86.8с.
Результат: Все 25 слов ушли к SPEAKER_01.
Фраза «Проблема в том, что...» (которую на самом деле сказал SPEAKER_00)
оказалась приписана другому человеку.
`
Наглядно это выглядело так:
`
Реальность (Pyannote):
Спикер 1 (SPEAKER_00) [85.05 ──────── 86.8]
Спикер 2 (SPEAKER_01) [86.9 ───────────────────── 106.39]
Сдвинутый ASR (GigaAM):
Слова: .....................||||||||||||||||||||||||||||||||
(все слова уехали в зону Спикера 2)
`
При этом тесты на покрытие (coverage check) радостно рапортовали: "Все 25 слов успешно сопоставлены со спикерами!". Система не видела ошибки, а пользователь читал откровенную кашу.
5 попыток исправить склейку (и почему они провалились)
Мы честно пытались спасти старую архитектуру. Вот хроники наших неудач:
* Попытка 1: Добавили люфт (padding) ±0.2 секунды. Если слово не попадает в интервал спикера точно, проверяем зону рядом.
Почему не сработало:* На стыках реплик зоны начали перекрываться. Алгоритм выбирал самый короткий сегмент из перекрытия, и слова «улетали» к автору коротких междометий.
* Попытка 2: Убрали люфт, ввели жесткое сравнение.
Почему не сработало:* Из-за сдвига времени слова начали зависать в «слепых зонах» между сегментами диаризации и вообще не получали спикера.
* Попытка 3: Проверка покрытия слов. Мы написали валидатор: если больше двух слов в сегменте не сопоставились — откатываемся на пропорциональное деление.
Почему не сработало:* Таймкоды были сдвинуты, но они покрывали все слова. Алгоритм считал, что всё отлично.
* Попытка 4: Разрез предложений по знакам препинания. Если видим, что внутри сегмента одного спикера диаризация фиксирует смену голоса, ищем ближайшую точку или вопросительный знак и режем текст там.
Почему не сработало: Граница смены спикера от Pyannote попала ровно посередине неделимой фразы: «Для этого мы должны получить [граница] выписку из трудовой»*. Разрезать предложение пополам было невозможно, а ближайшая точка была слишком далеко.
* Попытка 5: Игнорировать таймкоды слов, резать только по границам диаризации.
Why:* Pyannote иногда выдает микросегменты по 0.02 секунды (вздохи, шумы). При попытке нарезать текст по этим границам, слова дробились на бессмысленные слоги, а после фильтрации шумов фразы собирались в случайном порядке.
Решение: Diarization-First пайплайн
В конце концов мы поняли: мы боремся с симптомами, а не с болезнью. Пытаться синхронизировать две независимые шкалы времени, рассчитанные разными нейросетями на динамически изменяющемся аудио — это математический тупик.
Мы перевернули пайплайн с ног на голову.
`
БЫЛО:
Audio ──> ASR (крупные куски) ──> Текст ──┐
├──> merge_speakers() (попытка склеить)
Audio ──> Diarization (Pyannote) ─────────┘
СТАЛО:
Audio ──> Diarization (все аудио) ──> Временные реплики спикеров
│
▼ (нарезка аудио на точные куски)
ASR (распознавание каждого куска отдельно)
`
Теперь мы сначала прогоняем диаризацию на всём аудиофайле. Получаем точные границы реплик и метки спикеров. Затем мы нарезаем исходное аудио на эти кусочки и отправляем каждый кусочек в ASR по отдельности.
Спикер известен еще до распознавания. Склеивать больше ничего не нужно.
Вот как выглядит ядро нового пайплайна на Python:
`python
1. Сначала запускаем диаризацию на всём аудио
diar_segments = run_diarization(audio_data, SAMPLE_RATE)
2. Для каждого найденного интервала вырезаем аудио и распознаем текст
segments = []
for turn in diar_segments:
start_sample = int(turn["start"] * SAMPLE_RATE)
end_sample = int(turn["end"] * SAMPLE_RATE)
chunk_audio = audio_data[start_sample:end_sample]
# Распознаем конкретный чистый кусок речи одного спикера
text_result = transcribe_chunk(chunk_audio)
segments.append({
"start": turn["start"],
"end": turn["end"],
"text": text_result["text"],
"speaker": turn["speaker"], # Спикер определен изначально!
"words_confidence": text_result["words_confidence"]
})
`
Какова цена этого решения?
Мы не верим в «серебряные пули». У подхода Diarization-First есть свои компромиссы:
- Скорость: Общее время обработки выросло с 8 до 15-20 секунд, так как отправка множества коротких аудиокусочков в ASR создает накладные расходы.
- Контекст: На ультракоротких репликах (меньше 1 секунды) ASR-модель может сработать чуть хуже из-за недостатка контекста.
- Границы слов: На стыках быстрого диалога последнее слово одного спикера может быть слегка «подрезано» на стыке аудиофайлов.
Но мы осознанно выбрали этот путь. Для конечного пользователя стабильность разделения голосов в разы важнее, чем выигрыш нескольких секунд при загрузке.
Чему это нас научило
- Не верьте таймкодам слов ASR при анализе спикеров. Они плывут от кодеков, частоты дискретизации и перекодирования.
- 100% покрытие данных не означает их корректность. Наш алгоритм склейки не выдавал ошибок, он просто тихо врал пользователю.
- Если вы пишете четвертый костыль поверх третьего — меняйте архитектуру.
Логирование — ваш лучший друг. Без детального вывода промежуточных этаповmerge_speakers` мы бы еще месяц думали, что «Pyannote просто плохо работает с русским языком», хотя проблема была в смещении шкал.