Skip to content

WebRTC media transport

WebRTC - основной media path для браузерного клиента. WSMedia остается резервным транспортом для первичного подключения в сетях, где UDP/WebRTC/SRTP недоступен, и как forced diagnostic mode. Если WebRTC endpoint уже был успешно поднят, последующие stale/reconnect/stall восстанавливаются через WebRTC restart/backoff, а не через переключение в WSM.

Клиент не подключается напрямую к Translator. Он создает WebRTC endpoint на выбранной RTC route (rtc_node_id) через CommandLoop signaling, а RTC node / WebRTC Gateway связывает этот endpoint с существующим Translator и conference lifecycle.

Эта страница фиксирует transport contract: что считается источником правды, какие binding-поля обязательны, где проходит граница между conference lifecycle и WebRTC transport, и какие fallback/recovery решения допустимы.

Текущий codec contract:

  • audio: Opus через WebRTC;
  • video: H.264 для текущего browser WebRTC path;
  • WSMedia browser fallback тоже переносит H.264 RTP и декодирует его через WebCodecs;
  • VP8 остается в legacy native/recorder/fileplayer path и как совместимость для старых сервисов.

Почему нельзя подключить браузер прямо к Translator

Серверный media core VideoGrace исторически работает по plain UDP/RTP:

  • TranslatorPool выделяет один из UDP Translator-портов для устройства.
  • Translator разводит RTP по author_ssrc и receiver_ssrc.
  • Native-клиенты и WSMServer отправляют в Translator обычные RTP/RTCP пакеты.
  • WSMServer сейчас является мостом WebSocket binary frame -> UDP RTP.

Браузерный RTCPeerConnection не умеет отправлять такой plain RTP напрямую в UDP-порт Translator. WebRTC поверх UDP всегда несет свой transport stack:

  • ICE для выбора сетевого пути и consent checks;
  • DTLS handshake;
  • SRTP/SRTCP вместо plain RTP/RTCP;
  • SDP offer/answer и ICE candidates;
  • RTP/RTCP mux и обычно BUNDLE.

Значит, нужен WebRTC Gateway. Он терминирует ICE/DTLS/SRTP, получает обычные RTP/RTCP пакеты и связывает WebRTC tracks с уже существующей моделью device_id, author_ssrc, receiver_ssrc и Translator port.

Текущая схема

flowchart TB
    subgraph BrowserSide[Browser client]
        Browser["RTCPeerConnection"]
        ControlWS["ControlWS"]
    end

    subgraph ServerSide[VideoGrace Server]
        CommandLoop["CommandLoop"]
        Processor["Processor\nconference lifecycle"]
        Translator["TranslatorPool / Translator"]
        Gateway["RTC Gateway"]
        Legacy["Native / WSM / Recorder"]
    end

    ControlWS -->|join/device/signaling| CommandLoop
    CommandLoop --> Processor
    Processor -->|device_id / SSRC / port| CommandLoop
    CommandLoop -->|webrtc_offer / ICE| Gateway
    Browser -->|ICE + DTLS + SRTP| Gateway
    Gateway <-->|plain RTP/RTCP| Translator
    Translator <-->|plain RTP/RTCP| Legacy

CommandLoop остается каналом управления. Processor остается владельцем conference state. Gateway - только media adapter рядом с WSMServer, а не новая конференционная подсистема.

RTC route selection

Перед созданием WebRTC endpoint клиент запрашивает доступные RTC routes. Route - это логический handle WebRTC-ноды, а не сетевой адрес. Реальный ICE address приходит позже в SDP/ICE от выбранной ноды.

{
  "webrtc_routes_request": {}
}

Ответ:

{
  "webrtc_routes": {
    "routes": [
      {
        "rtc_node_id": "rtc-eu-1",
        "role": "rtc-translator",
        "version": "3.0.0",
        "capabilities": ["rtc", "webrtc", "rtp_bridge"],
        "messages_in": 128,
        "events_in": 64
      }
    ]
  }
}

Сервер возвращает только online CAN-ноды с role=rtc-translator и capability rtc. Порядок routes намеренно перемешивается, чтобы клиенты распределялись между нодами.

flowchart TD
    A["Need WebRTC endpoint"] --> B["webrtc_routes_request"]
    B --> C{"routes[] есть?"}
    C -->|yes| D["try route by order"]
    D --> E{"connected?"}
    E -->|yes| OK["use WebRTC"]
    E -->|no| F{"next route?"}
    F -->|yes| D
    F -->|no| G{"primary connect + auto mode?"}
    C -->|no| H{"in-process gateway available?"}
    H -->|yes| OK
    H -->|no| G
    G -->|yes| WSM["fallback to WSMedia"]
    G -->|no| Retry["WebRTC restart/backoff"]

Правила:

  • если route не подключился из-за ICE timeout или connect timeout, клиент пробует следующий rtc_node_id;
  • если все routes не сработали при первичном подключении в режиме auto, допускается WSMedia fallback;
  • если routes нет, сервер может использовать in-process gateway, если он собран и включен;
  • если WebRTC endpoint уже был поднят, route failure лечится WebRTC restart/backoff, а не WSM fallback.

Модель реализации

WebRTC в VideoGrace - транспортный адаптер вокруг существующей серверной media-модели. Он не создает участников, не решает права, не управляет видимостью и не заменяет Translator.

Компонент Ответственность
web-client Создает RTCPeerConnection, формирует SDP offer, отправляет signaling через ControlWS, следит за ICE/health/recovery.
ControlWS / CommandLoop Переносит webrtc_routes_request, webrtc_offer, webrtc_answer, webrtc_ice_candidate; сохраняет порядок control events.
Processor / conference lifecycle Создает участников и устройства, выдает device_id, author_ssrc, receiver_ssrc, port, применяет права и видимость.
RtcTranslator / in-process WebRTC Gateway Терминирует ICE/DTLS/SRTP, сопоставляет WebRTC track с media binding, мостит RTP/RTCP в Translator.
TranslatorPool / Translator Остается основным RTP router по author_ssrc и receiver_ssrc.
WSMedia Резервный WSS media transport и compatibility path, но не часть успешного WebRTC endpoint.

Главный инвариант: device_connect является источником правды для media device. WebRTC callbacks (ontrack, ICE connected, track open) не создают domain state. Они только подтверждают, что transport для уже созданного устройства начал работать.

Путь подключения всегда двухконтурный:

  1. Control contour создает/обнаруживает устройство через обычные команды конференции.
  2. Media contour поднимает WebRTC endpoint для уже существующего device_id и SSRC binding.
flowchart TB
    subgraph Control[Control lifecycle]
        Join[connect_to_conference_request]
        DeviceParams[device_params]
        DeviceConnect[device_connect / device_disconnect]
        Grants[conference grants and visibility]
    end

    subgraph WebRTC[WebRTC endpoint lifecycle]
        Routes[webrtc_routes_request]
        Offer[webrtc_offer]
        Answer[webrtc_answer]
        Ice[webrtc_ice_candidate]
        Health[ICE / RTP health / recovery]
    end

    subgraph MediaCore[Media core]
        Gateway[RTC Gateway]
        Translator[TranslatorPool / Translator]
    end

    Join --> DeviceParams --> DeviceConnect --> Offer
    Grants --> DeviceConnect
    Routes --> Offer --> Gateway
    Gateway --> Answer
    Ice <--> Gateway
    Gateway <--> Translator
    Health --> Offer

Endpoint identity

Endpoint - одно WebRTC-подключение для одного направления одного media device. При restart создается новый RTCPeerConnection, но domain binding остается прежним: тот же device_id, SSRC и Translator port.

Каждая publish/subscribe попытка должна иметь стабильный endpoint_id, уникальный в рамках клиентской сессии и scope. Клиент передает его в webrtc_offer и webrtc_ice_candidate, а сервер возвращает тот же endpoint_id в webrtc_answer и ICE events.

Рекомендуемый формат:

<scope>-<uuid>

Примеры:

Scope Endpoint
audio audio-publish-8b7c...
video video-publish-8b7c...
audio-subscribe audio-subscribe-8b7c...
video-subscribe video-subscribe-8b7c...

peer_id нужен для диагностики и сопоставления endpoint'ов, например local-mic-1005 или remote-video-1006-228800002.

stateDiagram-v2
    [*] --> Planned: device_connect or self device ready
    Planned --> Offering: create RTCPeerConnection + local offer
    Offering --> Answered: webrtc_answer
    Answered --> Checking: ICE candidates
    Checking --> Connected: ICE connected/completed
    Connected --> Ok: RTP progress
    Ok --> Stalled: no RTP/progress or disconnected
    Stalled --> Restarting: restart/backoff
    Restarting --> Offering: same binding, new endpoint attempt
    Ok --> Stopped: device_disconnect / leave / toggle off
    Stalled --> Error: restart budget exceeded
    Error --> Restarting: explicit user/reconnect kick
    Stopped --> [*]

Для диагностики endpoint нужно логировать не только peer_id, но и полный binding: conference_tag, scope, rtc_node_id, endpoint_id, device_id, author_ssrc, receiver_ssrc, port.

Binding по scope

scope определяет направление, набор обязательных полей и семантику SSRC. Без полного binding SDP/ICE может технически завершиться, но media endpoint не будет рабочим: Gateway не знает, с каким Translator port и SSRC его связывать.

Scope Direction Создается после Обязательный binding Что делает Gateway
audio Browser -> server self device_connect my=1 микрофона device_id, author_ssrc, port Принимает WebRTC Opus RTP, нормализует SSRC/PT и отправляет plain RTP в Translator.
video Browser -> server self device_connect my=1 камеры/демонстрации device_id, author_ssrc, port Принимает WebRTC H.264 RTP, нормализует SSRC/PT и отправляет plain RTP в Translator.
audio-subscribe Server -> browser remote device_connect my=0 микрофона device_id, author_ssrc, receiver_ssrc, port Регистрирует receiver на Translator, получает plain RTP автора, отправляет WebRTC audio track браузеру.
video-subscribe Server -> browser remote device_connect my=0 камеры/демонстрации device_id, author_ssrc, receiver_ssrc, port Регистрирует receiver, получает RTP, отправляет WebRTC video track и управляет keyframe/PLI.

Media flows

Все четыре media flow одинаковы по форме: сначала control lifecycle создает device/binding, затем WebRTC signaling поднимает transport для этого binding.

flowchart LR
    Device["device lifecycle\nid / SSRC / port"]
    Offer["webrtc_offer\nscope + binding"]
    Gateway["RTC Gateway"]
    Translator["Translator"]
    Browser["Browser track"]

    Device --> Offer --> Gateway
    Browser <-->|WebRTC track| Gateway
    Gateway <-->|plain RTP/RTCP| Translator

Publish microphone

Локальный микрофон публикуется после device_params и self device_connect. Gateway принимает Opus RTP из WebRTC, снимает SRTP, нормализует SSRC/PT под внутренний media core и отправляет plain RTP в Translator с author_ssrc.

sequenceDiagram
    participant C as Client
    participant S as CommandLoop
    participant G as RTC Gateway
    participant T as Translator

    C->>S: device_params(audio)
    S-->>C: device_connect(my=1, device_id, author_ssrc, port)
    C->>C: getUserMedia(audio) + RTCPeerConnection
    C->>S: webrtc_offer(scope=audio, binding)
    S->>G: start endpoint
    G->>T: bind author_ssrc/port
    G-->>S: webrtc_answer
    C-->>G: Opus SRTP
    G->>T: plain RTP(author_ssrc)

Publish camera / screen

Камера и демонстрация используют тот же publish contract, но video codec сейчас H.264-first. Gateway принимает RTP от браузера, нормализует SSRC в author_ssrc и отправляет plain RTP в Translator. Это сохраняет совместимость с native-клиентами, recorder и WSMServer.

Для browser-to-browser video Gateway может оптимизировать forwarding между WebRTC tracks, если источник тоже WebRTC. Эта оптимизация не отменяет Translator path: recorder, WSM, native и legacy clients по-прежнему завязаны на Translator.

Subscribe remote microphone

Remote audio subscribe создается из чужого device_connect. Клиент передает device_id, author_ssrc, receiver_ssrc и port в webrtc_offer(scope=audio-subscribe). Gateway регистрирует receiver на Translator, получает RTP автора и отправляет браузеру WebRTC audio track.

Важный browser-инвариант: входящий звук должен идти через нативный WebRTC/audio output. JS Opus decoder, ScriptProcessor и WebAudio graph не являются основным путем воспроизведения.

Subscribe remote camera / screen

Remote video subscribe тоже создается из чужого device_connect. Если источник native/WSM/recorder, Gateway получает RTP из Translator и отдает его в browser WebRTC track. Если источник WebRTC publisher, Gateway может использовать более короткий forwarding path, но обязан сохранить совместимость с Translator.

Keyframe requests идут через Gateway к publisher и должны быть throttled. Иначе decoder errors на одном клиенте могут превратиться в PLI/FIR flood для всей конференции.

Audio-first адаптация WebRTC

В конференции входящий и исходящий каналы участника могут быть сильно асимметричными. Типичный проблемный сценарий: несколько участников уже публикуют камеры и демонстрацию в хорошем качестве, затем в горячую конференцию заходит участник с узким входящим каналом. Его uplink может быть нормальным, поэтому остальные его видят и слышат, но его downlink забивается чужими видео до того, как отправители успеют снизить bitrate по max_input_bitrate.

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

Основные инварианты:

  • audio важнее remote video;
  • screen share обычно важнее камер, но при первичном входе слабого клиента может быть временно отложен;
  • один stalled remote audio stream не доказывает перегрузку локального downlink: это может быть sender-side проблема, route glitch или перезапуск конкретного publish;
  • max_input_bitrate - это hint для отправителей, а не мгновенная гарантия, что уже идущие потоки снизились;
  • WebRTC fallback в WSM не используется для уже поднятых RTC endpoint'ов.

Общая петля адаптации

flowchart LR
    Weak["Weak receiver\nтонкий downlink"] -->|speed test + Browser Network API| Control["ControlWS\nset_max_bitrate"]
    Control --> MemberList["member.max_input_bitrate"]
    MemberList --> Senders["Remote publishers\nupdate sender maxBitrate"]
    Senders -->|ниже RTP bitrate| Gateway["RTC Gateway / Translator"]
    Gateway --> Weak
    Weak -->|локальный downlink budget| Subs["Remote video subscriptions"]
    Subs -->|bandwidth_limited| UI["UI\nВидео отключено для звука"]

Адаптация состоит из двух независимых контуров:

Контур Где выполняется Что регулирует
Sender-side cap У отправителей Максимальный bitrate исходящей камеры/демонстрации с учетом max_input_bitrate участников.
Receiver-side budget У слабого получателя Сколько входящих remote video subscriptions можно держать одновременно, чтобы не убить audio.

max_input_bitrate и отправители

Клиент оценивает входящий канал через speed test и Browser Network API, затем отправляет set_max_bitrate. Сервер распространяет это значение как max_input_bitrate в member state. Отправители используют минимальный входящий лимит участников конференции как ограничение для своих RTCRtpSender параметров.

sequenceDiagram
    participant Weak as Weak client
    participant Control as ControlWS
    participant Server
    participant Sender as Existing sender
    participant RTC as RTC Gateway

    Weak->>Weak: speed test / Network API
    Weak->>Control: set_max_bitrate(input_kbps)
    Control->>Server: update member max_input_bitrate
    Server-->>Sender: member state update
    Sender->>Sender: recompute sender maxBitrate
    Sender->>RTC: lower outgoing RTP bitrate
    RTC-->>Weak: lower incoming media bitrate

Это не мгновенная защита. Между входом слабого клиента и применением новых sender caps есть окно, в котором старые камеры еще могут отдавать высокий bitrate. Поэтому нужен receiver-side join-settle.

Sender-side ramp-up

Даже если у отправителя еще нет точных данных о слабом получателе, новый publish не должен стартовать сразу на полном bitrate. Локальная камера и демонстрация стартуют с пониженного maxBitrate, затем плавно доходят до целевого значения.

flowchart LR
    T0["0 ms\ncamera 35%\nscreen 45%"] --> T1["2.2 s\n70% target"] --> T2["6 s\n100% target"]

Ramp применяется к обычному WebRTC publish и к сценарию, где локальная WSM capture session уже есть, но publish переводится в WebRTC. Все отложенные шаги ramp планируются через общую mediaRecoveryQueue, чтобы leave/stale/stop могли снять jobs по ключу устройства.

Join-settle для слабого получателя

Если клиент входит в конференцию и его входящий канал оценивается как слабый, он временно ограничивает собственные remote video subscriptions. Это защищает первые секунды входа, пока отправители получают max_input_bitrate и снижают свои outgoing sender caps.

sequenceDiagram
    participant Weak as Weak client
    participant Control as ControlWS
    participant Server
    participant Sender as Existing senders
    participant RTC as RTC Gateway

    Weak->>Control: set_max_bitrate(input_kbps)
    Weak->>Control: connect_to_conference_request
    Control-->>Weak: connect_to_conference_response
    Weak->>Weak: arm join-settle 4s
    Server-->>Sender: member max_input_bitrate update
    Sender->>Sender: apply sender cap / ramp
    Server-->>Weak: device_connect existing cameras/screen
    Weak->>Weak: hold remote video for 1.2s
    Weak->>RTC: subscribe max 1 video during settle
    Weak->>RTC: subscribe by budget after settle

Правила join-settle:

  • включается после успешного connect_to_conference_response, если входящий канал оценивается не выше 8 Mbps;
  • первые 1.2 секунды remote video subscriptions не стартуют;
  • до конца 4-секундного окна разрешается максимум одно входящее video subscription;
  • в это окно входит и screen share, потому что 2K демонстрация может забить канал сильнее нескольких камер;
  • после окна screen share переоткрывается первым, затем камеры добираются по downlink budget.

Клиент локально считает, сколько входящих камер можно держать. Оценка downlink берется как максимум из speed test и Browser Network API, потому что любой из источников может быть устаревшим или консервативным.

Из бюджета вычитается audio reserve:

audio_reserve = 160 kbps + 48 kbps * remote_audio_streams

Оставшийся бюджет делится на условную стоимость одной remote camera, сейчас около 800 kbps. Приоритет получает active speaker, затем уже поднятые/стабильные video sessions, затем остальные камеры. Отключенные бюджетом remote video получают mediaStatus=bandwidth_limited, чтобы UI показывал не ошибку, а понятное состояние "Видео отключено для звука".

stateDiagram-v2
    [*] --> Candidate
    Candidate --> BandwidthLimited: budget или join-settle держит video
    BandwidthLimited --> Subscribing: budget освободился
    Candidate --> Subscribing: budget позволяет
    Subscribing --> Ok: ICE + RTP progress
    Ok --> BandwidthLimited: audio-first protection
    BandwidthLimited --> [*]: device_disconnect / leave

Audio-stall protection

Remote audio stall сам по себе не всегда означает, что локальный downlink забит. Поэтому защита downlink включается только в двух случаях:

  • локальный downlink budget уже очень низкий, меньше 1200 kbps;
  • за короткое окно 8 секунд застопорились минимум два разных remote audio streams.
flowchart TD
    A["Remote audio stalled"] --> B{"Budget < 1200 kbps?"}
    B -->|yes| Protect["Enable audio-first protection 15s"]
    B -->|no| C{"Stalls from >=2 streams\nwithin 8s?"}
    C -->|yes| Protect
    C -->|no| Restart["Restart only this audio subscribe"]
    Protect --> Limit["Limit remote cameras"]
    Limit --> KeepAudio["Keep audio reserve first"]

При active protection remote cameras ограничиваются агрессивнее:

Downlink budget Разрешенные remote cameras
нет оценки 1
< 900 kbps 0
< 3500 kbps 1
>= 3500 kbps 2

Screen share при обычной protection не режется как камера. Исключение - join-settle при входе слабого клиента, где screen share тоже может быть отложен на первые секунды.

Очередь отложенных действий

Адаптация не должна плодить независимые setTimeout, которые переживают leave, stale cleanup или смену device id. Все delayed jobs идут через общую media recovery queue:

Job key Назначение
remote-video-downlink-budget:<reason> Пересчитать и применить remote video budget.
local-publish-ramp:<deviceId>:mid Поднять sender bitrate до промежуточного ramp уровня.
local-publish-ramp:<deviceId>:final Поднять sender bitrate до целевого уровня.

При выходе из конференции, hard cleanup, stopAllRemoteMediaSessions и остановке local publish эти jobs снимаются по prefix. Это важно для native и mobile-клиентов: старый timer не должен воскресить video subscription или sender bitrate после того, как пользователь уже вышел.

Настраиваемые параметры

Параметр Значение Смысл
REMOTE_VIDEO_JOIN_SETTLE_BUDGET_THRESHOLD_KBPS 8000 Включать join-settle для входа слабого клиента.
REMOTE_VIDEO_JOIN_SETTLE_MS 4000 Общая длительность join-settle окна.
REMOTE_VIDEO_JOIN_SETTLE_HOLD_MS 1200 Начальная пауза перед remote video subscriptions.
REMOTE_AUDIO_DOWNLINK_PROTECT_MS 15000 Длительность audio-first protection после подтвержденной downlink проблемы.
REMOTE_AUDIO_DOWNLINK_STALL_WINDOW_MS 8000 Окно, в котором считаются stalls разных audio streams.
REMOTE_CAMERA_VIDEO_TARGET_KBPS 800 Условная стоимость одной remote camera для downlink budget.
LOCAL_VIDEO_PUBLISH_RAMP_MID_MS 2200 Момент перехода sender ramp к 70%.
LOCAL_VIDEO_PUBLISH_RAMP_MS 6000 Момент перехода sender ramp к 100%.

Signaling API

Минимальный набор команд поверх CommandLoop.

webrtc_routes_request / webrtc_routes

{
  "webrtc_routes_request": {}
}
{
  "webrtc_routes": {
    "routes": [
      {
        "rtc_node_id": "rtc-eu-1",
        "role": "rtc-translator",
        "version": "3.0.0",
        "capabilities": ["rtc", "webrtc", "rtp_bridge"]
      }
    ]
  }
}

webrtc_offer

{
  "webrtc_offer": {
    "peer_id": "local-mic-1005",
    "conference_tag": "test",
    "scope": "audio",
    "rtc_node_id": "rtc-eu-1",
    "endpoint_id": "audio-publish-8b7c",
    "sdp": "v=0...",
    "device_id": 1059,
    "author_ssrc": 1214,
    "port": 5063
  }
}

Поля:

Поле Назначение
peer_id Диагностический id endpoint'а на клиенте.
conference_tag Конференция, к которой привязан media endpoint.
scope audio, video, audio-subscribe, video-subscribe.
rtc_node_id Выбранная route из webrtc_routes. Пустое значение разрешает серверный fallback, если он доступен.
endpoint_id Клиентский id endpoint'а; нужен для корреляции answer/ICE.
sdp Local SDP offer.
device_id Device lifecycle id.
author_ssrc SSRC автора/source.
receiver_ssrc SSRC получателя для subscribe path.
port Translator port из device_connect.

device_id, author_ssrc, receiver_ssrc и port являются media binding. Клиент берет их из обычного device lifecycle: device_params выдает device_id/author_ssrc, а device_connect CreatedDevice или remote device_connect возвращает Translator port и receiver_ssrc. Если binding не передан, сервер может завершить SDP/ICE handshake, но endpoint не будет корректно связан с Translator.

webrtc_answer

{
  "webrtc_answer": {
    "peer_id": "local-mic-1005",
    "conference_tag": "test",
    "scope": "audio",
    "rtc_node_id": "rtc-eu-1",
    "endpoint_id": "audio-publish-8b7c",
    "sdp": "v=0..."
  }
}

webrtc_ice_candidate

{
  "webrtc_ice_candidate": {
    "peer_id": "local-mic-1005",
    "conference_tag": "test",
    "scope": "audio",
    "rtc_node_id": "rtc-eu-1",
    "endpoint_id": "audio-publish-8b7c",
    "candidate": {
      "candidate": "candidate:...",
      "sdpMid": "0",
      "sdpMLineIndex": 0
    }
  }
}

scope определяет назначение binding:

  • audio - publish microphone;
  • video - publish camera/screen;
  • audio-subscribe - receive remote microphone;
  • video-subscribe - receive remote camera/screen.

Корреляция с текущей моделью устройств идет через device_id, author_ssrc, receiver_ssrc и port, которые уже приходят в device_connect.

ICE candidates можно отправлять как объект candidate, совместимый с RTCIceCandidate.toJSON(). Сервер также принимает flat-поля candidate, sdpMid, sdpMLineIndex.

Connect sequence

Эти sequence diagrams нужны для проверки реализации. В них видно главное: device_* команды идут раньше WebRTC signaling, а webrtc_offer всегда несет binding уже созданного device.

Publish

sequenceDiagram
    participant C as Client
    participant S as CommandLoop
    participant R as RTC node
    participant T as Translator

    C->>S: device_params
    S-->>C: device_connect(my=1, device_id, author_ssrc, port)
    C->>S: webrtc_routes_request
    S-->>C: webrtc_routes(routes[])
    C->>C: create RTCPeerConnection + local track
    C->>S: webrtc_offer(scope=audio/video, rtc_node_id, endpoint_id, binding)
    S->>R: CAN rtc.endpoint.start
    R->>T: bind translator port/ssrc
    R-->>S: webrtc_answer
    S-->>C: webrtc_answer
    C-->>S: webrtc_ice_candidate
    S-->>R: CAN rtc.endpoint.ice

Если publish restart происходит после stale/reconnect, новый device_params не отправляется. Клиент переиспользует существующий sourceDevice и поднимает новый WebRTC endpoint с тем же device_id/author_ssrc/port.

Subscribe

sequenceDiagram
    participant C as Client
    participant S as CommandLoop
    participant R as RTC node
    participant T as Translator

    S-->>C: device_connect(my=0, device_id, author_ssrc, receiver_ssrc, port)
    C->>S: webrtc_routes_request
    S-->>C: webrtc_routes(routes[])
    C->>C: create recvonly RTCPeerConnection
    C->>S: webrtc_offer(scope=audio-subscribe/video-subscribe, rtc_node_id, endpoint_id, binding)
    S->>R: CAN rtc.endpoint.start
    R->>T: register receiver_ssrc / adjust packet
    R-->>S: webrtc_answer
    S-->>C: webrtc_answer
    R-->>C: media track

Subscribe restart также не создает новое устройство. Он останавливает старый RTCPeerConnection и повторяет webrtc_offer для того же remote device_connect binding.

Инварианты

Эти правила важнее конкретной реализации Gateway:

  • Processor владеет conference membership, permissions, visibility/hearability и lifecycle устройств.
  • Translator остается RTP router; WebRTC Gateway не принимает решений о правах доступа.
  • WebRTC path не создает WSMedia sessions для media payload.
  • WSMedia остается fallback только для первичного подключения или forced diagnostic mode.
  • Успешный WebRTC endpoint после stale/reconnect восстанавливается через WebRTC restart/backoff.
  • Browser audio path не должен опираться на JS Opus decode/encode и ScriptProcessor как основной pipeline.
  • WebRTC SRTP-порты и Translator-порты 5060-5063 - разные transport layers.
  • Browser-to-browser forwarding допустим внутри Gateway, но не должен ломать Translator path для native, WSMedia, recorder и services.

Failover и recovery

WebRTC - предпочтительный media path, но не единственный. На первичном подключении UDP/SRTP может быть заблокирован, поэтому auto mode умеет уйти в WSMedia. После успешного WebRTC connect такое переключение запрещено: иначе reconnect/stale превращается в смену transport model и может создать дубликаты media sessions.

Режим клиента:

  • auto: сначала пробовать WebRTC; если endpoint изначально не поднялся, перейти на WSMedia.
  • webrtc: использовать только WebRTC для диагностики нового транспорта.
  • wsm: использовать только текущий WSMedia mux для диагностики и совместимости.

Условия перехода auto -> wsm:

  • первичный ICE не вышел в connected/completed за ограниченный таймаут;
  • DTLS/SRTP handshake завершился ошибкой;
  • первичный webrtc_offer не получил webrtc_answer;
  • браузер или платформа не поддерживает нужный WebRTC/audio path.

Условия, при которых auto -> wsm запрещен:

  • WebRTC endpoint уже был в состоянии connected/ok;
  • recovery выполняется после restore_stale_session;
  • health-check показал stalled или no inbound RTP progress у уже активной WebRTC session;
  • local publish получил disconnected/failed после успешного publish.

В этих случаях клиент перезапускает WebRTC endpoint с тем же device_id/sourceDevice, а не создает WSM-сессию. Это защищает device lifecycle: Processor, membership, device_id, author_ssrc, receiver_ssrc и TranslatorPool не должны зависеть от выбранного транспорта.

Во время активной конференции автоматический возврат wsm -> webrtc не обязателен. Его можно добавлять только если переключение будет безопасным и без заметного разрыва media.

Server-owned ICE

VideoGrace не должен зависеть от внешних STUN/TURN-сервисов для базового server-relay сценария. WebRTC Gateway работает как публичная UDP-точка самого VideoGrace Server или внешней RTC-ноды. Браузер получает ICE candidate через ControlWS и отправляет WebRTC/UDP на этот адрес.

Если сервер имеет публичный IP прямо на интерфейсе, дополнительных ICE-сервисов не требуется. Если сервер находится за NAT или libdatachannel видит только приватный адрес вроде 192.168.x.x, нужно явно указать адрес, который браузеры должны использовать.

В AdvertiseAddress должен быть указан именно внешний IP или DNS-имя, которое резолвится в этот IP:

export VG_WEBRTC_ADVERTISE_ADDRESS=core.videograce.ru

Та же настройка может жить в серверном конфиге:

[WebRTC]
AdvertiseAddress=core.videograce.ru

Если WebRTC.AdvertiseAddress не задан, сервер использует Network.Address / VG_ADDR.

Для эксплуатации лучше ограничить UDP-диапазон и открыть его на firewall/NAT. Эти порты отдельные от Translator-портов:

export VG_WEBRTC_PORT_RANGE_BEGIN=43000
export VG_WEBRTC_PORT_RANGE_END=43100
[WebRTC]
PortRangeBegin=43000
PortRangeEnd=43100

Если нужно привязать libdatachannel к конкретному локальному интерфейсу:

export VG_WEBRTC_BIND_ADDRESS=0.0.0.0
[WebRTC]
BindAddress=0.0.0.0

Инвариант: VG_WEBRTC_ADVERTISE_ADDRESS должен быть достижим с клиентской сети, а UDP-порты из диапазона должны приходить на VideoGrace Server. Эти порты отдельные от Translator-портов. Если UDP недоступен при первичном подключении, web-клиент в режиме auto может перейти на WSMedia.

Для внешнего RtcTranslator через CAN действует тот же ICE-инвариант, но адрес относится уже к RTC-ноде, а не к головному серверу. Core отдает клиенту только rtc_node_id; браузер получает реальный адрес RTC-ноды из SDP/ICE, которые возвращает сам worker. Поэтому на каждой внешней RTC-ноде нужно задавать свой VG_WEBRTC_ADVERTISE_ADDRESS и открывать ее UDP range.

Codec direction

Audio path использует Opus. Это обязательный WebRTC codec, он поддерживается браузерами и мобильными WebRTC stack'ами без дополнительных JS codecs.

Browser video path сейчас H.264-first. Web-клиент выставляет H.264 выше остальных codecs в WebRTC offer/answer. WSMedia fallback тоже использует H.264 через WebCodecs encoder/decoder и H.264 RTP packetizer/depacketizer.

Долгосрочный codec target:

  • iOS WebRTC не должен рассматриваться как VP8-capable target для основного path;
  • mobile native clients должны публиковать и принимать H.264;
  • native desktop может использовать OpenH264 как software fallback;
  • аппаратное ускорение нужно выносить в отдельные backend implementations: VideoToolbox на Apple, Media Foundation/NVENC/QSV на Windows, VAAPI/NVENC на Linux;
  • VP8 оставить как legacy/fallback для старых desktop/services path, но не как основной browser/mobile codec.

Gateway не должен становиться video transcoder по умолчанию. Его задача - WebRTC termination, RTP routing, keyframe/RTCP control и совместимость transport layers. Кодирование/декодирование должно жить на клиенте или в специализированных media services.

SDP, codec и RTP payload type

SDP answer обязан выбирать codec и RTP payload type из browser offer. Нельзя хардкодить browser-side dynamic payload type на стороне Gateway только потому, что внутренний media core использует фиксированное значение.

Текущие инварианты:

  • внутренний media core использует RTPPayloadType::ptOpus = 111 для Opus;
  • Firefox может предлагать Opus как 109, Chrome часто предлагает 111;
  • H.264 payload type тоже dynamic и выбирается из offer;
  • Gateway обязан разделять browser-side payload type и internal media-core payload type.

Правильная модель:

flowchart LR
    BrowserOffer["Browser SDP offer\nm=audio ... 109\na=rtpmap:109 opus/48000/2"]
    Gateway["Gateway\nselect_opus_candidate -> browser PT 109"]
    Answer["Gateway SDP answer\nm=audio ... 109"]
    Internal["Translator RTP\ninternal Opus PT 111"]
    BrowserRtp["WebRTC RTP to browser\nbrowser Opus PT 109"]

    BrowserOffer --> Gateway --> Answer
    Internal --> Gateway --> BrowserRtp

Gateway всегда разделяет два мира:

  • browser-side RTP должен использовать payload type, выбранный из browser SDP offer;
  • internal RTP в Translator должен использовать payload type, который ожидает media core.

Для publish path Gateway принимает browser RTP с browser payload type и перед отправкой в Translator нормализует:

  • RTP header extension layout приводится к compact VG RTP layout;
  • SSRC заменяется на author_ssrc из device_connect;
  • audio payload type заменяется на внутренний 111;
  • video payload type заменяется на внутренний codec/PT, который ожидает media core.

Для subscribe path Gateway делает обратное:

  • получает plain RTP из Translator с внутренним payload type;
  • сохраняет/нормализует SSRC автора;
  • перед отправкой в browser WebRTC track выставляет payload type, выбранный из browser offer;
  • для video дополнительно держит выбранный H.264 payload type и keyframe/PLI state.

Если answer выбирает payload type, которого не было в offer, разные браузеры ведут себя по-разному. Chrome может терпеть часть несовместимостей. Firefox часто поднимает ICE/DTLS и отдает track, но аудио остается немым или RTP не декодируется.

Поэтому при любом инциденте "ICE connected, track есть, звука нет" первым делом проверяется offer-summary-json и answer-summary-json, а не autoplay/output device.

Минимальная диагностика SDP:

[WebRTCPublish] offer-summary-json
[WebRTCPublish] answer-summary-json
[WebRTCSubscribe] offer-summary-json
[WebRTCSubscribe] answer-summary-json

Для аудио в Firefox ожидаем:

offer:  m=audio ... 109 ...
answer: m=audio ... 109

Не ожидаем:

offer:  m=audio ... 109 ...
answer: m=audio ... 111

Это ошибка Gateway negotiation, а не autoplay/output bug.

Production Gateway implementation

WebRTC Gateway - production-компонент сервера. Есть два runtime placement, но для клиента и CommandLoop это один и тот же signaling contract.

Режим Где выполняется Когда используется
In-process gateway VideoGraceServer, Server/WebRTC/LibDataChannelGateway.cpp + Engine/RTC/LibDataChannelBridge.cpp Путь по умолчанию для простого deployment без отдельной RTC-ноды.
External RTC node Services/RtcTranslator/RtcTranslator.cpp через CAN jobs rtc.endpoint.* Масштабирование и вынос WebRTC/SRTP CPU, ICE state и UDP-портов на отдельные узлы.

Оба режима используют libdatachannel как WebRTC termination layer:

  • ICE и candidate negotiation;
  • DTLS handshake;
  • SRTP/SRTCP;
  • SDP offer/answer;
  • WebRTC media tracks;
  • SCTP/datachannel support, который сейчас не является целью audio/video path.

Кодеки, микширование, конференционная модель, права доступа, TranslatorPool и device/SSRC lifecycle остаются в VideoGrace. Gateway должен получить из WebRTC обычные RTP/RTCP packets и передать их в существующий UDP media core.

Code map

Код Назначение
Engine/Proto/CmdWebRTC.* Команды webrtc_routes_request, webrtc_offer, webrtc_answer, webrtc_ice_candidate.
Server/WebRTC/IWebRTCGateway.h Backend-neutral интерфейс Gateway.
Server/WebRTC/WebRTCServer.* Facade на уровне Processor, аналогично WSMServer для WebRTC signaling.
Server/WebRTC/WebRTCGateway.cpp Factory: выбирает in-process backend, external CAN wrapper или unavailable stub.
Server/WebRTC/LibDataChannelGateway.cpp In-process adapter между CommandLoop offer/ICE и Engine/RTC bridge.
Server/WebRTC/CanRtcGateway.cpp Adapter для внешних RTC CAN workers.
Engine/RTC/LibDataChannelBridge.* Общий WebRTC/RTP bridge: PeerConnection, SDP, ICE, RTP/PT/SSRC mapping, Translator UDP binding.
Services/RtcTranslator/RtcTranslator.cpp Внешняя RTC-нода, принимающая CAN jobs rtc.endpoint.start/ice/stop/list.

Новые клиенты не должны зависеть от того, выбран in-process или external gateway. Для клиента это всегда один contract: webrtc_routes_request, webrtc_offer, webrtc_answer, webrtc_ice_candidate и media binding из device_connect.

Runtime selection

По умолчанию сервер собирается с WebRTC gateway:

cmake -S . -B build
cmake --build build --target VideoGraceServer

Флаги сборки:

Флаг Значение
VG_ENABLE_LIBDATACHANNEL=ON Включает libdatachannel backend. Это default.
VG_WITHOUT_WEBRTC=ON Принудительно выключает WebRTC/libdatachannel gateway и оставляет только stub.

Если gateway выключен, сервер может принять signaling-команды, но WebRTC media endpoint не поднимется. Такой режим нужен только для diagnostic/build fallback, не для production.

External RTC nodes включаются runtime-настройкой:

export VG_WEBRTC_EXTERNAL_RTC_ENABLED=1

или конфигом:

[WebRTC]
ExternalRtcEnabled=1

Когда external RTC включен и CAN-ноды role=rtc-translator доступны, сервер отдает их в webrtc_routes. Если внешняя нода недоступна или route не поднялся, клиент пробует следующий route или in-process fallback по обычным правилам route selection.

Сборка libdatachannel binaries

Исходники libdatachannel лежат в thirdparty/libdatachannel, headers - в Lib/libdatachannel/include. Бинарные артефакты не собираются основным CMake автоматически и не должны становиться частью обычного исходного diff. Для каждой платформы они собираются отдельно, публикуются как пакет Lib/libdatachannel, а в рабочую копию подтягиваются через Lib/upd_*_libs.py по той же модели, что и остальные bundled-библиотеки.

Для macOS arm64:

thirdparty/libdatachannel/build_vg_macos.sh

Для Linux x86_64:

thirdparty/libdatachannel/build_vg_linux.sh

Для Windows x64 из Developer PowerShell for Visual Studio:

powershell -ExecutionPolicy Bypass -File thirdparty/libdatachannel/build_vg_windows.ps1 -Platform win_x64

Для Windows Win32:

powershell -ExecutionPolicy Bypass -File thirdparty/libdatachannel/build_vg_windows.ps1 -Platform win32

Скрипт:

  • подтягивает build-only зависимости в thirdparty/libdatachannel/deps;
  • фиксирует libsrtp на v2.7.0, потому что libdatachannel 0.24.2 ожидает target srtp2;
  • собирает static datachannel-static;
  • на Windows собирает libdatachannel и его зависимости с MSVC runtime /MT, как основной Server.vcxproj;
  • на Windows после CMake configure дополнительно патчит generated .vcxproj, чтобы исключить случайный /MD в dependency targets;
  • использует OpenSSL из Lib/OpenSSL/lib/x64 для win_x64 и Lib/OpenSSL/lib/win32 для win32;
  • копирует результат в платформенную папку Lib/libdatachannel/lib/*.

Ожидаемые артефакты:

Lib/libdatachannel/lib/mac_arm64/libdatachannel-static.a
Lib/libdatachannel/lib/mac_arm64/libjuice-static.a
Lib/libdatachannel/lib/mac_arm64/libsrtp2.a
Lib/libdatachannel/lib/mac_arm64/libusrsctp.a

Lib/libdatachannel/lib/lin_x64/libdatachannel-static.a
Lib/libdatachannel/lib/lin_x64/libjuice-static.a
Lib/libdatachannel/lib/lin_x64/libsrtp2.a
Lib/libdatachannel/lib/lin_x64/libusrsctp.a

Lib/libdatachannel/lib/win_x64/datachannel-static.lib
Lib/libdatachannel/lib/win_x64/juice-static.lib
Lib/libdatachannel/lib/win_x64/srtp2.lib
Lib/libdatachannel/lib/win_x64/usrsctp.lib

Lib/libdatachannel/lib/win32/datachannel-static.lib
Lib/libdatachannel/lib/win32/juice-static.lib
Lib/libdatachannel/lib/win32/srtp2.lib
Lib/libdatachannel/lib/win32/usrsctp.lib

После публикации пакета для платформы сервер проверяется так:

cmake -S . -B /tmp/videograce-cmake-webrtc-check -DVG_ENABLE_LIBDATACHANNEL=ON
cmake --build /tmp/videograce-cmake-webrtc-check --target VideoGraceServer

Production contract

Gateway считается production-ready только если выполняет весь contract:

  • signaling webrtc_offer/answer/ice_candidate идет через ControlWS;
  • publish microphone: browser mic track -> Gateway -> Translator;
  • subscribe microphone: Translator -> Gateway -> browser remote audio track;
  • publish camera/screen: browser video track -> Gateway -> Translator;
  • subscribe camera/screen: Translator или Gateway forwarding -> browser remote video track;
  • WSMedia fallback не регрессит для сетей, где UDP/WebRTC/SRTP недоступен;
  • native/WSM/recorder compatibility сохраняется через Translator path;
  • keyframe requests throttled, чтобы browser RTCP feedback не создавал бесконечный PLI/FIR flood;
  • SDP answer выбирает payload type из offer, а internal RTP нормализуется отдельно.

Если какой-то пункт ломается, это regression в WebRTC transport или Gateway bridge, а не отсутствие feature.

Дальше

Следующие доработки не меняют базовый WebRTC contract:

  1. Довести H.264-first до native/mobile/media services, сохранив VP8 только как legacy fallback.
  2. Улучшить RTP inspection для H.264 keyframe detection по IDR/STAP-A/FU-A.
  3. Добавить OpenH264 software fallback для native desktop.
  4. Для KMP/mobile clients использовать WebRTC как первый transport contract, без встраивания C++ media core.
  5. Hardware acceleration оформлять отдельными backend implementations, не смешивая с базовой H.264-интеграцией.