Транскрибация

Как работает потоковое распознавание речи: архитектура Streaming ASR через WebSocket и опыт внедрения

Разбор архитектуры Streaming ASR через WebSocket на примере GigaAM. Четыре критические ошибки на Android Chrome: от sampleRate до perMessageDeflate и способы их решения.

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

Потоковое распознавание речи (Streaming ASR) позволяет преобразовывать голос пользователя в текст практически без задержки. Такие системы используются в голосовых ассистентах, сервисах транскрибации, видеоконференциях и AI-агентах.

На первый взгляд реализация выглядит простой: получить звук через getUserMedia, отправить его по WebSocket и вывести результат ASR. Однако на практике большая часть проблем возникает не в самой модели машинного обучения, а в браузере, транспортном слое и особенностях обработки аудиопотока.

В TEOM мы столкнулись с этим комплексом проблем, когда разрабатывали демо-режим для . Пользователь нажимает кнопку «Попробовать», разрешает доступ к микрофону и видит текст в процессе распознавания. Ниже мы разберем архитектуру решения и четыре инфраструктурные ошибки, которые полностью ломали работу системы на Android Chrome.

Кому пригодится такая архитектура

Эта архитектура идеально подходит для: * Сервисов и онлайн-стенографии; * Голосовых ассистентов и AI-агентов; * Контакт-центров (суфлирование оператору на лету); * Видеоконференций (субтитры в реальном времени); * Медицинских сервисов и образовательных платформ.

Почему мы отказались от Web Speech API

Первая мысль веб-разработчика — взять нативное браузерное Web Speech API. Однако этот инструмент работает как «черный ящик», жестко зависит от проприетарных серверов (в Chrome трафик уходит в облако Google) и не предоставляет гарантий стабильности и доступности.
Для нашего проекта мы выбрали : она отлично справляется со сложной фонетикой русского языка и нативно поддерживает потоковую выдачу токенов. Однако описанные ниже проблемы не зависят от конкретной ASR-модели — они возникнут при использовании Whisper, Vosk или любого другого движка, если транспортный слой реализован некорректно.

Архитектура Streaming ASR: от микрофона до текста

Мы сознательно отказались от прямого подключения браузера к ASR. Между клиентом и моделью находится промежуточный Node.js-прокси, который отвечает за установку WebSocket-соединения, буферизацию сообщений и передачу аудиопотока в . `` Микрофон │ ▼ AudioContext <-- [Ошибка 1: Игнорирование sampleRate] │ ▼ AudioWorklet │ ▼ WebSocket │ ▼ Node Proxy <-- [Ошибка 2: Next.js Rewrite, Ошибка 4: Race condition] │ ▼ WebSocket (Rust)<-- [Ошибка 3: perMessageDeflate] │ ▼ Rust Backend (Axum) │ ▼ ASR (GigaAM) `

Почему WebSocket, а не HTTP?

Для непрерывной передачи потокового аудио стандартные REST-запросы не подходят. HTTP требует нового запроса на каждый чанк аудио, что создает неприемлемо высокую сетевую задержку из-за постоянного TCP и TLS Handshake. Протокол WebSocket (RFC 6455) решает эту проблему, устанавливая одно постоянное, двунаправленное соединение с минимальным накладным оверхедом.

AudioWorklet вместо ScriptProcessor

Согласно современной спецификации Web Audio API,
ScriptProcessorNode признан устаревшим. Он работает в главном потоке JavaScript, вызывая заикания интерфейса (UI freezes) и джиттер при сборке аудио. AudioWorklet работает в отдельном фоновом потоке, нарезая аудио на чанки по 1280 сэмплов (передача каждые ≈80 мс), что гарантирует идеально ровный бинарный поток PCM.

4 критические ошибки при реализации (на примере Android Chrome)

1. Браузер создавал неправильную частоту дискретизации

Симптомы: WebSocket подключается успешно, статус —
streaming, но модель постоянно возвращает пустой текст. Причина: Бэкенд ожидает аудио частотой 16000 Hz. При вызове new AudioContext({ sampleRate: 16000 }) десктопный Chrome послушно выдавал 16 кГц. А вот мобильный Android Chrome аппаратно игнорировал этот параметр, принудительно создавая контекст на 48000 Hz для экономии ресурсов на ресемплинг. Звук ускорялся в 3 раза, и ASR-модель воспринимала его как шум. Решение: Считывать реальную частоту постфактум из созданного AudioContext и передавать её конфигурационным сообщением на бэкенд перед отправкой аудио. `javascript // Передача реальной частоты дискретизации (useRealtimeASR.ts) const audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 }); if (audioCtx.state === 'suspended') { await audioCtx.resume(); } // Передаем реальный sampleRate, который может быть 48000 на Android ws.send(JSON.stringify({ sample_rate: audioCtx.sampleRate })); ` Бэкенд на Rust (realtime_asr.rs) динамически считывает частоту и соответствующим образом настраивает WAV-конвертер и порог буферизации: `rust let mut sample_rate: u32 = 16000; // по умолчанию if let Some(sr) = val.get("sample_rate").and_then(|v| v.as_u64()) { sample_rate = sr as u32; } // Динамически рассчитываем размер буфера (2 секунды звука) let threshold = (sample_rate * 2) as usize; let wav_bytes = pcm_to_wav(pcm, sample_rate); `

2. Next.js rewrites ломали WebSocket-соединение

Симптомы: Мгновенный обрыв соединения с кодом ошибки
1002 (Protocol error) и ошибками UTF-8 encoding error на бэкенде. Причина: Конфликт маршрутизации. Правило rewrite в next.config.js пыталось обработать HTTP Upgrade, в то время как кастомный сервер server-https.js также слушал это событие. В результате к бэкенду летело два одновременных WebSocket-рукопожатия на один запрос. Решение: Убрать Next.js rewrite для API-запросов и вынести проксирование WebSocket строго в кастомный Node.js сервер. `javascript // server-https.js if (parsedUrl.pathname.startsWith('/api/')) { const proxyReq = http.request({ hostname: '127.0.0.1', port: 8081, path: req.url, method: req.method, headers: { ...req.headers, host: '127.0.0.1:8081' } }, (proxyRes) => { res.writeHead(proxyRes.statusCode, proxyRes.headers); proxyRes.pipe(res); }); req.pipe(proxyReq); return; } `

3. perMessageDeflate вызывал ошибки RSV1

Симптомы: Ошибка
Invalid WebSocket frame: RSV1 must be clear на стороне Rust-сервера. Причина: Библиотека ws в Node.js по умолчанию включает компрессию perMessageDeflate. Бэкенд на Rust не ожидал сжатия бинарных PCM-данных, трактуя бит RSV1 (указывающий на сжатие фрейма) как битый заголовок пакета. Решение: Явно отключить компрессию фреймов (perMessageDeflate: false) на обеих сторонах проксирования. `javascript // На сервере Node (принимает соединение от браузера) const wss = new WebSocket.Server({ noServer: true, perMessageDeflate: false }); // На клиенте Node (проксирует запрос к Rust бэкенду) const backendWs = new WebSocket(backendUrl, { perMessageDeflate: false }); `

4. Race Condition при установке WebSocket-подключения

Симптомы: Конфигурационное сообщение с
sample_rate терялось через раз на мобильных устройствах. Причина: Фронтенд отправлял настройки сразу после события open на клиенте, но прокси-серверу требовалось еще 30–50 мс для установки соединения со вторым плечом — Rust-бэкендом. Решение: Реализовали массив pending. Прокси буферизует любые сообщения от клиента до тех пор, пока не получит событие open от бэкенда. `javascript const pending = []; let backendOpen = false; backendWs.on('open', () => { backendOpen = true; for (const { data, isBinary } of pending) { backendWs.send(data, { binary: isBinary }); } pending.length = 0; }); clientWs.on('message', (data, isBinary) => { if (backendOpen && backendWs.readyState === WebSocket.OPEN) { backendWs.send(data, { binary: isBinary }); } else { pending.push({ data, isBinary }); } }); `

Результаты оптимизации

После устранения всех четырех инфраструктурных ошибок мы получили стабильную работу потоковой транскрибации на всех устройствах:
МетрикаДо исправленийПосле исправлений
Время до первого токена~3 сек (если соединение выживало)< 500 мс
Ошибки WebSocketРегулярные (код 1002)Отсутствуют
Работа на Android ChromeНестабильно / Пустой текстСтабильно
Потеря пакетовДо 15% на старте соединения0% (ровный поток)

Выводы и рекомендации

Опираясь на этот опыт, мы рекомендуем разработчикам потоковых голосовых интерфейсов придерживаться следующих правил с первого дня:
  • Никогда не полагайтесь на запрашиваемый sampleRate в браузере. Всегда считывайте реальный audioCtx.sampleRate` и передавайте его на бэкенд для корректной конфигурации.
  • Выносите WebSocket-прокси из Next.js rewrites. Подобная маршрутизация не предназначена для HTTP Upgrade соединений и вызывает дублирование рукопожатий.
  • Отключайте WebSocket-компрессию для бинарного аудио. Сжатие PCM-потока на лету не дает существенного выигрыша в размере пакета, но сильно увеличивает нагрузку на процессор и часто ломает сторонние парсеры фреймов.
  • Буферизуйте инициализационные сообщения. Не отправляйте данные в WebSocket «вслепую» — убедитесь, что вся цепочка проксирования от клиента до конечного бэкенда завершила хэндшейк.

Пример интеграции

Подключиться к нашему Real-time ASR WebSocket API вы можете с помощью следующего простого скрипта:

Все технические решения описаны на основе нашего реального опыта. Кодовые примеры упрощены для читаемости. Полная реализация доступна в репозитории TEOM|os.
asrwebsocketreal-timeandroidgigaamnextjs

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

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