Разработка

Как мы полтора месяца пытались понять, кто говорит в аудиофайле (и перевернули пайплайн)

Честный технический разбор: как мы боролись со сдвигом таймкодов ASR, почему 5 стандартных решений склейки GigaAM и Pyannote провалились, и как Diarization-First спас наш караоке-плеер.

9 июля 2026 г.·12 мин чтения
← Все статьи

Есть задачи, которые звучат просто, пока не начинаешь писать под них код. «Определить, кто говорит в аудио» — ровно такая.

Кажется, что в 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 просто плохо работает с русским языком», хотя проблема была в смещении шкал.
Теперь эта архитектура работает в караоке-плеере TEOM OS. Она успешно переваривает файлы любого качества и происхождения, потому что мы убрали саму причину возникновения ошибок.

Попробуйте сами

Вы можете протестировать точность работы нашего нового пайплайна прямо здесь. Загрузите аудиозапись разговора двух людей (например, интервью или рабочий созвон) и посмотрите, как ИИ справляется с разделением спикеров:
Если вы хотите интегрировать распознавание речи с точной диаризацией в свой продукт, вы можете использовать наш API:
диаризацияgigaampyannoteasrsaasразработка

Попробуйте TEOM бесплатно

Загрузите файл и получите результат за секунды.