Потоковое распознавание речи (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.