Как мы спроектировали, реализовали и проверили в реальных условиях модуль видеосвязи для видеоинтервью и самостоятельных видеотестов в Recruiter.AI — и какие технические проблемы пришлось решить по пути.
Сразу обозначим рамки статьи. WebRTC — большая тема, по которой написаны целые книги. Это не учебник по WebRTC, а разбор конкретной архитектуры и инженерных решений, которые мы приняли для видеозвонков в Recruiter.AI.
Код видеозвонков доступен в репозитории GDMN Meet. Его можно попробовать на meet.gdmn.app или увидеть в работе в сценарии интервью Recruiter.AI.
1. Почему понадобилось свое решение
Recruiter.AI от Andersen Lab — платформа для найма с поддержкой ИИ. Две ее ключевые функции — видеоинтервью между кандидатом и интервьюером и самостоятельный видеотест, где кандидат записывает ответы на вопросы один перед камерой, — зависят от видеопотока, который должен стабильно работать не только в идеальных условиях.
Пользователь может подключаться из дома, из гостиницы, через нестабильный мобильный интернет, за корпоративным файрволом или из сети с национальной DPI-фильтрацией. Для такой среды недостаточно просто вызвать WebRTC API и рассчитывать, что браузер все сделает сам.
В статье разберем:
- общую архитектуру и причины, по которым она устроена именно так;
- собственный минимальный сигнальный сервер на Socket.IO;
- STUN, TURN и проблему юрисдикций, в том числе ограничения сетей, фильтруемых российским РКН;
- паттерн «идеального согласования» (Perfect Negotiation) и буферизацию ICE-кандидатов;
- многоуровневое восстановление соединения для нестабильных сетей;
- обнаружение замершего медиапотока, когда браузер все еще считает соединение активным;
- различия между Chrome, Firefox и Safari и конкретные обходные решения;
- проверку оборудования и горячую замену камер и микрофонов во время звонка;
- конвейер записи: компоновку через
canvas, загрузку фрагментами и исправление для Safari, на поиск которого ушло два дня; - инструменты диагностики, которые помогают проверять все это в реальных сетях.
2. Общая картина
На верхнем уровне в системе есть четыре плоскости:
-
Медиаплоскость (P2P). Два
RTCPeerConnectionобмениваются зашифрованным DTLS-SRTP-трафиком. При удачном сценарии медиаданные идут напрямую между браузерами, без промежуточного сервера. - Плоскость сигнального обмена. Минимальный Socket.IO-сервер на стороне приложения пересылает типизированные сообщения между участниками комнаты и хранит только минимальное состояние присутствия, нужное для защиты от повторных входов.
- ICE-плоскость. STUN-серверы Google, Cloudflare и наш собственный coturn помогают проходить NAT. Если прямое P2P-соединение невозможно, медиатрафик идет через TURN-серверы поверх TLS на порту 443.
-
Приемник записи. API загрузки фрагментами принимает 30-секундные медиаблоки, которые создает
CallRecorder.
На клиенте ответственность разделена между двумя хуками, связанными небольшим интеграционным слоем. Первый управляет RTCPeerConnection, сигнальным обменом и восстановлением соединения. Второй отвечает за камеры, микрофоны, подключение и отключение устройств, а также за локальный медиапоток. Такое разделение оказалось важным: замена устройств во время звонка сама по себе достаточно сложна, и смешивать ее с состоянием сигнального обмена было бы ошибкой.
3. Сигнальный сервер: минимальный, восстанавливаемый, свой
Для сигнального обмена мы используем точку подключения Socket.IO как часть сервера приложения. У нее три задачи:
- Пересылать сообщения. Сервер не хранит состояние звонка, а только передает сообщения между участниками. Благодаря этому протокол можно развивать без изменений на серверной стороне.
- Восстанавливать сессию после краткого обрыва. Если у кандидата на несколько секунд пропал Wi-Fi, страница не должна требовать ручной перезагрузки.
- Отслеживать присутствие в минимальном объеме. Сервер должен понимать, кто находится в комнате, чтобы отклонять повторный вход одного и того же участника.
Именно второй пункт стал причиной, по которой мы выбрали Socket.IO, а не обычный WebSocket. В Socket.IO есть connectionStateRecovery: механизм буферизует события в течение заданного окна и восстанавливает сессию, если клиент успевает переподключиться. У нас это окно равно пяти минутам.
Сам протокол компактный: девять типов сообщений — JOIN/LEAVE, ENUM, CALLING, ANSWER_CALL, SIGNAL, SOCKET_PING/PONG, RESTART_CALL, END_CALL, MAKE_CALL. SIGNAL передает и SDP, и ICE-кандидаты. В каждом сообщении есть fromId/toId, поэтому серверу не нужно хранить сведения о том, кто именно с кем разговаривает.
Последовательность успешной установки звонка:
В начальной фазе подключения есть две важные детали:
- Каждая попытка подключения отправляет новый
instanceId(UUID v4) вместе сparticipantId. Если вторая вкладка входит с тем жеparticipantId, сервер отвечаетalready_in_room, и вторая вкладка прекращает подключение. Так мы обнаруживаем и отклоняем дублирующиеся входы — частую причину жалоб вроде «я вижу себя в сетке два раза». - При закрытии вкладки клиент отправляет LEAVE через
navigator.sendBeacon().window.onbeforeunloadв мобильных браузерах ненадежен;sendBeaconработает лучше.
4. STUN, TURN и проблема юрисдикций
Получить ICE-кандидаты сравнительно просто: достаточно указать для RTCPeerConnection несколько публичных STUN-серверов. Конфигурация по умолчанию:
stun:stun.l.google.com:19302stun:stun.cloudflare.com:3478- наш собственный сервер для резервирования
Сложная часть — TURN. Существенная доля пользователей не может установить прямое P2P-соединение: корпоративные сети, симметричные NAT, мобильные операторы с NAT операторского класса (carrier-grade NAT) и, что особенно важно для Recruiter.AI, пользователи внутри национально фильтруемых сетей. В таких случаях медиатрафик должен идти через промежуточный TURN-сервер.
Поэтому мы подняли собственный coturn и открыли TURN на порту 443. URL ICE-сервера выглядит так:
turns:coturn.our_server.com:443
Порт 443 важен. Многие корпоративные файрволы и национальные DPI-фильтры пропускают TLS по TCP/443, потому что блокировка этого трафика нарушит работу HTTPS. TURN-сервер на этом порту, работающий внутри корректного TLS-туннеля, для большинства DPI выглядит как обычная HTTPS-сессия. Поэтому мы используем TURN поверх TLS на :443, а не стандартные транспорты UDP 3478 или TCP 5349.
Но при работе с пользователями из стран с жесткой фильтрацией трафика возникает еще одна проблема.
Проблема российской фильтрации
Российская инфраструктура национальной фильтрации трафика — система РКН, включая ТСПУ на точках пиринга провайдеров, — блокирует не только известные адреса. Соединения могут прерываться и для узлов, чьи IP-адреса или SNI-имена попали в списки фильтрации. Кроме того, фильтрация становится все агрессивнее к трафику, который похож на WebRTC: долгоживущим UDP-потокам или характерным STUN-пакетам поверх TCP. TURN-сервер, который безупречно работает из Амстердама, может за ночь стать недоступным из Москвы, если соседний сервис из того же IP-диапазона попал под фильтрацию.
Практический вывод: одного TURN-развертывания, даже качественного, недостаточно. Мы стали размещать TURN-серверы в разных юрисдикциях, чтобы у пользователей из конкретного региона всегда был доступный сервер-посредник с IP-адресом и hostname, которые не блокируются на их сетевом маршруте. Конфигурация WebRTC вычисляется для каждой организации во время выполнения через внутренний реестр сервисов, а конфигурация из переменных окружения служит запасным вариантом. Это позволяет направлять клиентов в подходящий TURN-пул без нового развертывания.
Сборка конфигурации
Сборщик конфигурации превращает JSON из переменных окружения в RTCConfiguration. Он небольшой, но ошибки в значениях по умолчанию здесь приводят к особенно неприятным последствиям, поэтому мы сделали его строгим:
export const getRTCConfiguration = ({ stun, turn, icePolicy }: WebRTCConfig): RTCConfiguration => {
const config: RTCConfiguration = {
iceServers: [
...turn.map(s => ({
urls: s.urls,
username: s.username || '1',
credential: s.credential || '1',
})),
...stun.map(s => ({ urls: s.urls })),
].filter(Boolean),
iceTransportPolicy: icePolicy,
};
return config;
};
icePolicy настраивается: по умолчанию это 'all', но на уровне конфигурации его можно переключить в 'relay'. Это удобно для отладки и для клиентов, которым по юридическим причинам нужен единый маршрут через TURN. Код звонка при этом не меняется.
5. Идеальное согласование (Perfect Negotiation) с буферизацией ICE
Оба участника могут одновременно попытаться создать предложение соединения (offer). В WebRTC такая коллизия называется конфликтом предложений (glare). Паттерн W3C Perfect Negotiation назначает одну сторону «вежливой» (polite peer) и позволяет ей откатить собственное локальное описание, если входящее предложение конфликтует с уже созданным локальным предложением. Мы реализуем этот подход через стандартные флаги makingOffer, ignoreOffer и polite. Вежливой стороной у нас выступает принимающий участник.
Важная практическая деталь: ICE-кандидаты могут прийти раньше, чем будет установлено удаленное описание (remoteDescription). Это особенно часто проявляется в медленных сетях: SDP доходит с задержкой, а ICE-кандидаты начинают поступать почти сразу. В современных браузерах вызов addIceCandidate до setRemoteDescription приводит к ошибке. Поэтому мы буферизуем ICE-кандидаты для каждого участника и очищаем буфер только после успешного setRemoteDescription. На практике в буфере обычно всего несколько кандидатов, но без этого механизма звонок на нестабильных сетях может не установиться без явной ошибки.
6. Восстановление: шаг за шагом
Эта часть потребовала больше всего итераций. WebRTC дает connectionState, iceConnectionState, iceGatheringState, signalingState, но ни одно из этих состояний нельзя считать исчерпывающим источником правды. Соединение с connectionState === 'connected' все еще может передавать ноль байт, если сетевой маршрут перестал пропускать трафик. А состояние iceConnectionState === 'disconnected' иногда восстанавливается само через секунду, если дать браузеру время.
Поэтому мы объединили несколько сигналов и сделали ступенчатую схему восстановления с пятью порогами:
const heartbeatDelay = 2500;
const heartbeatThreshold = heartbeatDelay * 2; // 5 s
const statsDelay = 5_001;
const recoveryDelay = 18_000; // full restart
const iceRestartDelay = recoveryDelay / 2; // 9 s — ICE restart
const videoWarmUpDelay = 8_000;
const waitingForInternetDelay = 60_000; // give up
Схема выглядит так:
В текстовом виде это работает так:
-
Каждые 2,5 секунды каждая сторона отправляет
SOCKET_PING, а вторая отвечаетSOCKET_PONG. Два пропуска подряд, то есть 5 секунд, означают, что путь через сигнальный сервер, скорее всего, недоступен. -
Примерно каждые 5 секунд вызывается
pc.getStats(), после чего сравниваются счетчики RTP-байтов. Если не изменились ни входящие, ни исходящие байты, медиапоток считается замершим независимо от состояния WebRTC. - При первом признаке проблемы (
connectionState === 'failed', ICE disconnected/closed, неактивный медиапоток или отсутствие передачи данных) мы сохраняем отметкуpreProblemDiscovered. Если на следующем цикле проблема сохраняется, она повышается доproblemDiscovered. Такая задержка на один цикл убрала большую часть ложных срабатываний в тестах. -
Через 9 секунд подтвержденной проблемы, если соединение с сигнальным сервером живо и текущая сторона является инициатором звонка, вызывается
pc.restartIce(). Это запускает повторное ICE-согласование без пересозданияRTCPeerConnection: операция требует мало ресурсов и обычно выполняется быстро. -
Через 18 секунд выполняется полный
restartCall(): текущийRTCPeerConnectionзакрывается, через сокет отправляетсяRESTART_CALL, вторая сторона также очищает свое состояние, после чего соединение создается заново. Это более ресурсоемкий, но надежный вариант восстановления. -
Если недоступен сам сокет или
navigator.onLine === false, перезапуск не запускается: для него все равно нет рабочего канала управления. В этом состоянии мы ждем до 60 секунд; только после этого интерфейс показывает сообщение: «Connection lost. Please check internet connection.»
У этой логики много условий и переходов, поэтому мы многократно проверяли ее в условиях искусственно ограниченной сети. Ключевой блок из цикла проверки статистики:
if (elapsed > recoveryDelay) {
if (!socket?.connected || !navigator.onLine || missedHeartbeat(p.socketPingReceived)) {
if (elapsed > waitingForInternetDelay) {
if (!socket?.connected) {
endCall(p, 'Connection lost. Please check internet connection.');
} else {
endCall(p);
}
} else {
updateParticipant(p.id, { healthCheck: Date.now() });
}
} else {
if (p.role === 'caller') {
restartCall(p);
} else {
// caller must manage the call, but if it avoids its responsibilities...
if (elapsed > (waitingForInternetDelay + recoveryDelay)) {
endCall(p);
} else {
updateParticipant(p.id, { healthCheck: Date.now() });
}
}
}
}
Есть два важных ограничения:
-
Перезапуск инициирует только вызывающая сторона. Если обе стороны одновременно начнут пересоздавать соединение, они будут постоянно конфликтовать на уровне SDP. Принимающая сторона реагирует на сигнал перезапуска, но сама его не запускает. Если же инициатор перестал выполнять эту роль — например, браузер заморозил его вкладку, — принимающая сторона завершает звонок самостоятельно после более длинного таймаута (
waitingForInternetDelay + recoveryDelay = 78 s). - Время «с момента проблемы» считается от
Math.max(problemDiscovered, lastSocketConnected, socketPingRestored, lastIceStateChange). Если сокет кратко восстановился или ICE-состояние на секунду вернулось в норму, таймер восстановления сбрасывается. Без этого звонок можно было бы завершить уже после того, как кратковременный сбой фактически прошел.
7. Как мы находим замерший медиапоток
Один из самых полезных фрагментов кода в проекте оказался очень небольшим. Мы отслеживаем изменение счетчиков RTP-байтов из pc.getStats(). Если за полный интервал проверки не изменились ни отправленные, ни полученные байты, соединение считается неработоспособным независимо от значения iceConnectionState:
function isDataTransmitted<S>(p: Participant<S>) {
if (
p.statInterval &&
p.bytesReceived !== undefined &&
p.bytesTime !== undefined &&
p.bytesPrevReceived !== undefined &&
p.bytesPrevTime !== undefined &&
p.bytesSent !== undefined &&
p.bytesPrevSent !== undefined
) {
const deltaTime = p.bytesTime - p.bytesPrevTime;
const deltaReceived = p.bytesReceived - p.bytesPrevReceived;
const deltaSent = p.bytesSent - p.bytesPrevSent;
// only judge if there were enough time to transmit some data
// Connection is alive if EITHER data is being received OR sent (not both required)
return deltaTime < statsDelay || (deltaReceived > 0 || deltaSent > 0);
} else {
return true;
}
}
Проверка deltaReceived > 0 || deltaSent > 0, а не &&, была осознанным решением. У одного из участников может быть выключен микрофон, а часть медиаданных может временно идти только в одном направлении. Если считать соединение живым только при одновременном росте обоих счетчиков, система будет ошибочно завершать нормальные звонки. Поэтому для проверки достаточно, чтобы данные передавались хотя бы в одну сторону.
Также есть короткий запас времени на появление первого медиапотока после установки звонка (videoWarmUpDelay = 8_000). Некоторые браузеры, особенно Safari, заметно задерживают первый видеокадр даже после connectionState === 'connected'. Если проверять отсутствие видео раньше восьми секунд, система будет выдавать ложные срабатывания.
8. Браузерные войны: Chrome, Firefox, Safari
Больше всего времени в проекте заняли различия между браузерами. Вот конкретные случаи, которые стоит зафиксировать.
Safari: MediaRecorder требует timeSlice
В Chrome и Firefox MediaRecorder.start() без аргументов возвращает один большой Blob при вызове stop(); если передать timeSlice в миллисекундах, данные будут приходить периодически. В Safari вызов start() без timeSlice может не отдавать данные вообще. При этом рекордер выглядит рабочим: нет ошибок, промисы не отклоняются, но ondataavailable не приносит полезных данных.
На этом мы потеряли два дня в сценарии самостоятельного видеотеста. Исправление — флаг useTimeSlice, включаемый только для нужного браузера, в обоих классах рекордера:
this.useTimeSlice = browserName === "Safari";
…
if (this.useTimeSlice && this.TIME_SLICE > 0) {
this.mediaRecorder.start(this.TIME_SLICE); // 1000 ms
} else {
this.mediaRecorder.start();
}
Есть и второй уровень проблемы: на границе 30-секундного фрагмента рекордер останавливается и запускается снова. В Safari данные снова появляются только если передавать timeSlice в start() при каждом запуске. Поэтому тот же флаг используется и при перезапуске рекордера.
Firefox: названия устройств пустые, пока не запросишь доступ
navigator.mediaDevices.enumerateDevices() возвращает label: "" для каждого устройства, пока пользователь хотя бы раз не выдал доступ через getUserMedia(). Chrome может заполнить подписи до запроса, если сайт уже был авторизован раньше; Firefox — нет. Поэтому мы сначала перечисляем устройства, затем запрашиваем доступ, после чего перечисляем устройства повторно и показываем обновленные подписи в списках выбора.
Выбор MIME
Chrome и Firefox предпочитают WebM: VP8/VP9 для видео и Opus для аудио. Safari надежно кодирует только MP4 с AVC и AAC. Мы при загрузке проверяем поддержку и выбираем лучшую доступную комбинацию, а для Safari запасным вариантом используем 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"'. Это решение влияет и на расширение загружаемого файла (.webm или .mp4), по которому серверная часть выбирает нужный конвейер последующей обработки.
iOS
На iOS в ограничениях для видео мы всегда указываем facingMode: 'user', чтобы по умолчанию открывалась фронтальная камера. deviceId: { exact: … } на iOS иногда выбирает не тот объектив, который ожидает пользователь. Это был небольшой, но устойчивый источник ошибок, пока мы не стандартизировали поведение через facingMode.
9. Оборудование: проверить, заменить, восстановить
Интервью и самостоятельные видеотесты — это записываемая оценка реальных людей, поэтому оборудование должно работать правильно. Здесь помогают три вещи.
9.1 Проверка микрофона перед звонком
Перед входом в звонок пользователь проходит проверку звука в модальном окне с живым индикатором громкости. Мы создаем AudioContext + AnalyserNode на отдельном потоке getUserMedia({ audio: { deviceId: { exact } } }), снимаем частотные данные, отслеживаем пиковый уровень в dB и сравниваем его с порогом −35 dB. Если микрофон пользователя не достигает этого порога, мы предупреждаем его до начала звонка, а не тогда, когда интервьюер уже ждет.
9.2 Горячая замена во время звонка
Если кандидат подключил USB-гарнитуру во время звонка, соединение не должно прерываться. Для этого нужны две вещи:
- Хук медиаустройств слушает
navigator.mediaDevices.ondevicechangeс задержкой в 1 секунду: при подключении устройство часто порождает несколько событий подряд. - Когда пользователь выбирает новое устройство,
RTCPeerConnectionне пересогласуется. Мы заменяем трек черезRTCRtpSender.replaceTrack():
// useVideoCall switchTrack (wired from useMediaDevices)
for (const p of participantsRef.current) {
if (p.state === 'in-call' && p.pc) {
const sender = p.pc.getSenders().find(s => s.track?.kind === newTrack.kind);
if (sender) {
await sender.replaceTrack(newTrack); // no renegotiation
}
}
}
localStreamRef.current = newStream;
replaceTrack здесь именно тот инструмент, который нужен: новое согласование по схеме «предложение/ответ» (offer/answer) не требуется, удаленная сторона видит непрерывный трек, а рекордер, если он уже работает, продолжает запись.
9.3 Восстановление, когда устройство исчезло
Если активная камера или микрофон исчезают — пользователь отключил USB-устройство, разрешение было отозвано на уровне ОС или браузер выбрал другое устройство, — включается двухступенчатое восстановление:
-
Мягкое восстановление.
track.onendedпомечает тип устройства как требующий замены. Затем срабатывает обработчикdevicechangeс задержкой, мы заново перечисляем устройства, выбираем первое доступное устройство нужного типа и вызываемswitchDevicesчерез тот же путьreplaceTrack, что и при обычной ручной замене. Для удаленного участника звонок продолжается без заметного разрыва. -
Жесткое восстановление. Если замены нет — все камеры отключены или разрешения отозваны, — локальный медиапоток корректно останавливается, сохраняется контекст восстановления со старыми и новыми device IDs, а наверх поднимается типизированная ошибка (
isNotAllowedError,isNotFoundError,isNotReadableError,isOverconstrainedError). Ее принимает отдельный интерфейс для ошибок медиаустройств и показывает правильное действие: «Разрешите доступ к камере», «Подключите камеру», «Закройте другое приложение, которое использует камеру» и так далее.
Есть еще одна важная деталь: виртуальный фон создает синтетический трек с искусственным deviceId. Если записать этот ID обратно в состояние выбранной камеры, в следующий раз пользователь откроет настройки и увидит UUID, который не соответствует ни одному реальному устройству. Исправление — разделить выбор пользователя (selectedCameraId) и текущее рабочее состояние (currentCamera). Когда виртуальный фон включен, мы сохраняем исходный ID выбранной камеры, а не ID синтетического трека.
Все операции с устройствами сериализованы через Semaphore, чтобы быстрые клики по выпадающему списку устройств не создавали перекрывающиеся getUserMedia-вызовы и брошенные треки.
10. Конвейер записи
У записи был собственный набор сложностей: видеозвонок многопользовательский — интервьюер, кандидат, иногда больше людей, — а на выходе нужен один удобный для просмотра артефакт.
Для звонка (CallRecorder):
-
<video>каждого участника отрисовывается в сетку 2×2 на canvas на каждом кадре анимации.canvas.captureStream()превращает результат вMediaStreamс одним составным видеотреком. - Этот составной поток передается в один
MediaRecorderдля видео. - Параллельно аудиотрек каждого участника передается в свой
MediaRecorder. Раздельное аудио упрощает последующую транскрибацию по спикерам — не нужно разделять уже смешанный звук — и позволяет не сводить WebRTC-аудиотреки на лету с потерей качества. - Каждые 30 секунд рекордеры переключаются на новый фрагмент:
stop()отдает последнийBlobчерезondataavailable, затемstart(TIME_SLICE)начинает следующий фрагмент. КаждыйBlobсразу загружается сinterviewId,participantId,timestampи расширением медиатипа. Если загрузка не удалась, повторяется отправка только этого фрагмента: один сетевой сбой не должен уничтожить всю запись.
Для самостоятельного видеотеста (AssessmentRecorder) компоновка не нужна: говорит один человек, поэтому достаточно одного рекордера для объединенного потока. Дополнительно AssessmentRecorder дает барьер остановки на основе промиса: stopRecording() возвращает объект Promise, который завершается только после того, как последний Blob прошел через ondataavailable. Благодаря этому код интерфейса, который хочет заменить устройство, точно знает, что поток уже можно безопасно разбирать.
Оба рекордера учитывают обходное решение для Safari с timeSlice из §8.
11. Тестирование в реальном мире
Юнит-тесты ловят баги протокола, но ни один юнит-тест не скажет, что coturn во Франкфурте на этой неделе недоступен из сети конкретного российского провайдера. Поэтому мы сделали клиентскую диагностическую проверку.
Для каждого сервера она создает одноразовый RTCPeerConnection, указывает ему ровно один STUN- или TURN-сервер, открывает канал данных (data channel), чтобы принудительно запустить сбор кандидатов, и ждет результата ICE-процедуры. Возможные результаты:
- Рабочий STUN-сервер должен дать кандидата типа
srflx. - Рабочий TURN-сервер должен дать кандидата типа
relay. - Все остальное — тайм-аут через 10 секунд или ошибка соединения — означает, что этот сервер недоступен для этого клиента.
Результат приводится к простой форме { url, type, status, candidates[], duration } и показывается на внутренней диагностической странице. Когда пользователь пишет «звонок не подключается», он может нажать одну кнопку и сразу увидеть, какие серверы доступны из его сети. Это сократило отладку с многодневной переписки до одного скриншота.
В сценарии интервью поверх серверных проверок есть еще слой контроля нарушений (anti-cheating), который отмечает переключения вкладок во время интервью. Это не совсем WebRTC-задача, но механизм работает через тот же сокетный канал, а пропавший сигнал о переключении вкладки бывает полезным косвенным признаком проблемы с соединением.
12. Что мы вынесли
Коротко и без особого порядка:
-
Не доверяйте ни одной отдельной машине состояний WebRTC. Перепроверяйте ее по изменениям RTP-счетчиков через
getStats().'connected'— это заявление, а не факт. -
Буферизуйте ICE-кандидаты. На медленном канале они действительно приходят раньше, чем завершается
setRemoteDescription. - Восстанавливайте соединение постепенно. Пульсовая проверка → ICE restart → полный перезапуск → ожидание восстановления сети. Слишком ранний полный перезапуск хуже, чем еще несколько секунд работы в деградировавшем состоянии.
- Восстановлением должна управлять одна сторона. Перезапуск запускает инициатор звонка, а принимающая сторона обрабатывает полученный сигнал. Если перезапуск запускают оба участника, начинаются конфликты при согласовании SDP.
- Поднимайте собственный coturn на :443 с TLS. Бесплатные STUN-серверы годятся; бесплатных TURN-серверов либо нет, либо они не масштабируются, либо находятся не там, где нужно.
- Размещайте TURN в нескольких юрисдикциях, если ваши пользователи находятся за национальными фильтрами. Российский случай сделал это для нас обязательным, но та же логика работает для корпоративного DPI и строгих мобильных операторов где угодно. Конфигурация на уровне клиента позволяет выбрать подходящий TURN-пул без нового развертывания.
-
Используйте
replaceTrackдля замены устройств. Пересогласовывать весьRTCPeerConnectionна каждый клик «сменить микрофон» медленно и ненадежно. - Разделяйте выбор пользователя и текущее рабочее состояние, когда появляются виртуальные фоны или другие синтетические треки. Это избавляет от трудноуловимых ошибок в настройках устройств.
-
Safari требует
timeSliceдляMediaRecorder. Всегда. И после каждого stop/start его нужно передавать снова. -
Firefox требует повторного
enumerateDevices()после выдачи разрешения. Иначе список устройств будет заполнен пустыми подписями. - Сделайте собственную проверку доступности серверов. Это небольшая задача, но она позволяет диагностировать проблемы в пользовательской сети за 30 секунд.
Видеозвонки Recruiter.AI продолжают развиваться: впереди настройка предпочтительных кодеков, одновременная передача нескольких видеопотоков разного качества (simulcast) и автоматическая адаптация качества к доступной пропускной способности канала. Но описанная здесь архитектура уже выдержала тысячи реальных интервью и самостоятельных видеотестов, в том числе в сетях, где WebRTC обычно ведет себя непредсказуемо. Если вы создаете похожую систему, начинать стоит не с интерфейса, а со схемы восстановления соединения и TURN-стратегии. От этих двух решений зависит все остальное.





Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.