golang

Как я сделал виджет видеозвонков для сайтов с транскрипцией речи в реальном времени

  • вторник, 27 января 2026 г. в 00:00:10
https://habr.com/ru/articles/988974/

Я давно увлекаюсь и изучаю технологии WebRTC. Устанавливал для клиентов множество WebRTC медиа серверов и кастомизировал их. Но постоянно не хватало гибкости. В итоге обнаружил чистую реализацию WebRTC на Golang, которая умеет и MESH, и SFU. Сейчас буду рассказывать, что удалось разработать и в чем польза.

Всё началось с простой идеи - дать возможность посетителям сайта в один клик связаться с оператором по видеосвязи. Казалось бы, задача тривиальная: WebRTC давно стал стандартом, готовых решений море. Но когда я начал копать глубже, оказалось, что дьявол кроется в деталях.

Зачем это вообще нужно

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

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

Выбор архитектуры

Первый вопрос - какую топологию WebRTC использовать. Вариантов три: Mesh, MCU и SFU.

В Mesh-топологии каждый участник напрямую соединяется со всеми остальными. Это нормально работает для двух-трёх человек, но при большем количестве участников нагрузка на клиента растёт экспоненциально. Для видеоколл-центра, где одновременно может быть много звонков, это не вариант.

MCU (Multipoint Control Unit) декодирует все входящие потоки на сервере, микширует их в один и отправляет каждому участнику. Нагрузка на клиента снижается, но сервер должен транскодировать видео - а это дорого по ресурсам.

SFU (Selective Forwarding Unit) оказался золотой серединой. Сервер просто пересылает медиапотоки между участниками без декодирования. Клиент получает отдельные потоки от каждого участника и сам решает, как их отображать. Нагрузка на сервер минимальна, масштабируется хорошо.

Почему Go и Pion

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

А ключевым фактором стала библиотека Pion - это полноценная реализация WebRTC на чистом Go. В отличие от привязок к нативным библиотекам вроде libwebrtc, Pion написан с нуля на Go и не требует CGO для базовой функциональности.

Что такое Pion и почему это круто

Pion - это не просто обёртка над каким-то C++ кодом. Это полноценный WebRTC стек, написанный на Go: ICE, DTLS, SRTP, SCTP - всё реализовано нативно. Проект живой, активно развивается, хорошая документация и куча примеров.

Для SFU Pion подходит идеально. Можно работать с RTP-пакетами напрямую, не занимаясь декодированием видео. Получил пакет от одного участника - переслал другому. Всё в горутинах, всё асинхронно, Go для такого и создан.

Вот типичный паттерн работы с Pion в SFU:

// Создаём PeerConnection для нового участника
peerConnection, _ := webrtc.NewPeerConnection(config)

// При получении трека от участника
peerConnection.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
    // Создаём локальный трек для пересылки
    localTrack, _ := webrtc.NewTrackLocalStaticRTP(
        track.Codec().RTPCodecCapability,
        track.ID(),
        track.StreamID(),
    )

    // Пересылаем RTP-пакеты без декодирования
    go func() {
        for {
            packet, _, _ := track.ReadRTP()
            localTrack.WriteRTP(packet)
        }
    }()

    // Добавляем этот трек всем остальным участникам комнаты
    for _, peer := range room.GetOtherPeers(participantID) {
        peer.AddTrack(localTrack)
    }
})

Код простой и понятный. Никакой магии, никаких абстракций поверх абстракций. Pion даёт доступ к низкоуровневым примитивам WebRTC, и ты сам решаешь, как их использовать.

Ещё один плюс - Pion модульный. Не нужен полный WebRTC стек? Можно взять только ICE или только DTLS. Нужно кастомное поведение? Можно переопределить практически всё.

Фронтенд

Фронтенд написан на React с TypeScript. Redux управляет состоянием, что оказалось критически важным для синхронизации множества асинхронных событий: подключение к комнате, получение медиапотоков, обработка сообщений сигнализации, отображение транскрипции.

Для транскрипции речи использовал Yandex SpeechKit. Он поддерживает русский язык на хорошем уровне и имеет streaming API, что позволяет получать результаты распознавания в реальном времени, пока человек ещё говорит.

WebRTC: подводные камни

Теория WebRTC выглядит просто: обмениваемся SDP-офферами через сигнальный сервер, устанавливаем ICE-кандидаты, и всё работает. На практике же куча неочевидных проблем.

Первая - NAT traversal. Большинство пользователей сидят за NAT, и для установки прямого соединения нужны STUN и TURN серверы. STUN помогает узнать внешний IP-адрес, а TURN проксирует трафик, когда прямое соединение невозможно. Для разработки хватает публичных STUN-серверов Google, но для продакшена понадобился собственный TURN-сервер - корпоративные файрволы любят блокировать UDP-трафик.

Вторая проблема - согласование кодеков. Разные браузеры поддерживают разные видеокодеки: Chrome предпочитает VP8 и VP9, Safari лучше работает с H.264. SFU должен уметь пересылать потоки без перекодирования, поэтому важно правильно настроить SDP-манипуляции, чтобы выбирался кодек, поддерживаемый всеми участниками.

Третья - управление bandwidth. Когда канал связи ограничен, нужно адаптивно снижать качество видео. Pion предоставляет механизмы для этого через REMB (Receiver Estimated Maximum Bitrate), но настройка требует экспериментов. Слишком агрессивное снижение битрейта - пикселизация, слишком мягкое - зависания.

Сигнализация через WebSocket

Для обмена SDP-офферами и ICE-кандидатами нужен сигнальный канал. Взял WebSocket - двусторонняя связь с низкой задержкой.

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

Отдельная история - переподключение при обрыве связи. WebSocket может разорваться по куче причин: нестабильный интернет, переключение между Wi-Fi и мобильной сетью, уход устройства в сон. Реализовал exponential backoff с jitter для переподключения и механизм восстановления состояния комнаты после успешного реконнекта.

Транскрипция речи: путь к реальному времени

Изначально думал, что с транскрипцией всё будет просто: захватываем аудио, отправляем на сервер распознавания, получаем текст. Реальность оказалась сложнее.

Первая проблема - формат аудио. Web Audio API выдаёт данные в формате Float32 с частотой дискретизации, которую поддерживает устройство (обычно 44100 или 48000 Гц). Yandex SpeechKit принимает PCM16 с частотой 16000 Гц. Пришлось реализовать ресемплинг на клиенте.

Алгоритм ресемплинга: накапливаем сэмплы в буфер, усредняем каждые три значения (для перехода с 48000 на 16000 Гц), конвертируем float в int16. Важный момент - little-endian порядок байтов, иначе Yandex возвращает мусор вместо текста.

Вторая проблема - задержка. Если отправлять аудио слишком маленькими чанками, overhead от сетевых пакетов съедает всю пропускную способность. Если слишком большими - пользователь видит текст с заметной задержкой. Экспериментально нашёл оптимальный размер чанка в 125 миллисекунд (2000 сэмплов при 16 кГц).

Третья проблема - промежуточные и финальные результаты. SpeechKit отправляет partial results, пока человек говорит, и final result, когда фраза закончена. Partial results постоянно меняются, что создаёт эффект "прыгающего" текста. Решил это разделением отображения: provisional текст показывается курсивом и обновляется на месте, а final текст добавляется в историю и больше не меняется.

Архитектура виджета

Виджет должен быть максимально независимым от сайта, на который он встраивается. Это значит - изоляция стилей и JavaScript-кода.

Для изоляции стилей использовал Shadow DOM. Весь UI виджета рендерится внутри shadow root, и CSS сайта на него не влияет. Это избавило от проблем с конфликтующими селекторами и позволило использовать простые классы вроде .button или .panel без страха, что стили сломаются.

Для изоляции JavaScript виджет экспортирует единственный глобальный объект с методами инициализации. Внутри всё работает в замыкании, не загрязняя глобальное пространство имён.

Размер виджета удалось удержать в пределах 15 килобайт после минификации и gzip-сжатия. Это важно, потому что виджет загружается на каждой странице сайта, и лишние килобайты - это лишние миллисекунды загрузки.

Панель оператора

На стороне оператора нужен интерфейс для управления входящими звонками. Реализовал его как SPA на том же React, но с отдельной точкой входа.

Оператор авторизуется, и система показывает ему очередь ожидающих клиентов. Каждая карточка содержит имя клиента и время ожидания. При клике на карточку оператор "принимает" звонок, и оба участника попадают в видеокомнату.

Важный момент - управление состоянием операторов. Если оператор закрыл вкладку или потерял связь, он не должен получать новые звонки. Для этого реализовал heartbeat: клиент периодически отправляет ping, и если сервер не получает его в течение определённого времени, оператор помечается как offline.

Для авторизации использовал JWT-токены. При логине сервер возвращает токен, который сохраняется в httpOnly cookie. Все последующие запросы автоматически включают этот cookie, и middleware на сервере проверяет валидность токена.

База данных

PostgreSQL хранит всю персистентную информацию: операторов, историю звонков, транскрипции.

Схема получилась простой. Таблица операторов содержит логин, хеш пароля (bcrypt), имя и роль. Таблица очереди звонков хранит текущие ожидающие запросы с позицией в очереди и статусом. Таблица истории - завершённые звонки с длительностью и ссылкой на оператора. Таблица транскрипций - распознанный текст с привязкой к комнате и участнику.

Интересная деталь - хранение длительности аудио для биллинга. Yandex SpeechKit тарифицирует по секундам распознанного аудио, поэтому записываю audio_duration_ms для каждой транскрипции. Это позволяет точно оценивать затраты и планировать бюджет.

Деплой и инфраструктура

Для деплоя подготовил полный набор Infrastructure as Code. Terraform создаёт инфраструктуру в облаке: VPC с приватной подсетью, виртуальные машины, security groups с правилами для WebRTC-трафика, опционально managed PostgreSQL.

Ansible конфигурирует серверы: устанавливает Go и Node.js, настраивает Nginx как reverse proxy с WebSocket support, получает SSL-сертификаты через Let's Encrypt, создаёт systemd-сервис для приложения.

GitHub Actions автоматизирует CI/CD: при пуше в main собирается фронтенд и бэкенд, прогоняются тесты, артефакты деплоятся сначала на staging, затем (после ручного подтверждения) на production.

Отдельно стоит упомянуть настройку Nginx для WebSocket. По умолчанию Nginx закрывает соединение через 60 секунд неактивности, что ломает и WebSocket-сигнализацию, и STT-стриминг. Пришлось увеличить таймауты до часа и правильно настроить проксирование upgrade-заголовков.

Что ещё можно улучшить

Проект работает, но есть области для улучшения. Масштабирование на несколько SFU-серверов требует механизма роутинга: нужно понимать, на каком сервере находится комната, и направлять туда новых участников. Можно использовать Redis для координации или реализовать свой discovery-протокол.

Запись звонков пока не реализована. Технически это возможно: SFU может сохранять проходящие через него RTP-пакеты и позже декодировать их в видеофайл. Но это существенно увеличивает нагрузку на сервер и требует много места для хранения.

Качество транскрипции зависит от качества микрофона и уровня фонового шума. Можно добавить noise suppression на клиенте или использовать более продвинутые модели распознавания, но это увеличит задержку или стоимость.

В итоге

За несколько месяцев разработки получился работающий продукт. WebRTC оказался мощной, но сложной технологией, требующей понимания кучи нюансов. Транскрипция речи в реальном времени добавила ещё один уровень сложности, но результат того стоит - видеть, как слова собеседника превращаются в текст прямо во время разговора, реально впечатляет.

Главный вывод: не стоит недооценивать "простые" задачи. За каждой кажущейся тривиальной функцией скрывается множество edge cases и технических деталей. Но именно это и делает разработку интересной.

Если вас заинтересовал проект или есть похожие задачи - пишите мне в Telegram, обсудим технические детали или возможное сотрудничество.