Implementar vídeo WebRTC con buffer de jitter y NACK en un cliente Qt
WebRTC utiliza UDP para minimizar la sobrecarga de transmisión. Los clientes intercambian descriptores SDP, usan STUN/TURN para atravesar NATs, ICE para mantener conexiones y un servidor de señalización para coordinación. Los servidores SFU/MCU actúan como intermediarios para reenvío de flujos. Un flujo VP8 de 1080p@30fps requiere ~600 Kbps sin cuadros clave. La pérdida de paquetes aumenta drásticamente la carga mediante solicitudes PLI y NACK. El buffer de jitter resuelve los problemas de ordenamiento de paquetes.
Tareas clave: servidor de señalización/MCU con soporte NACK, mecanismo de retransmisión, buffer de jitter del lado del cliente.
Servidor Pion con soporte NACK
El servidor se implementa en Go usando la biblioteca Pion. Registraremos interceptores predeterminados para NACK:
type Controller interface {
HandleConnection(c *common.SafeWebSocket)
JoinRoom(peer *common.Peer, msg Msg) error
LeaveRoom(peer *common.Peer, msg Msg) error
}
type controller struct {
logger *zap.Logger
roomRepo repository.RoomRepo
api *webrtc.API
}
func NewController(logger *zap.Logger, roomRepo repository.RoomRepo) Controller {
settingEngine := webrtc.SettingEngine{}
settingEngine.SetAnsweringDTLSRole(webrtc.DTLSRoleServer)
mediaEngine := &webrtc.MediaEngine{}
mediaEngine.RegisterDefaultCodecs()
interseporRegistry := interceptor.Registry{}
if err := webrtc.RegisterDefaultInterceptorsWithOptions(mediaEngine, &interseporRegistry,
webrtc.WithNackGeneratorOptions(nack.GeneratorSize(8192)),
webrtc.WithNackResponderOptions(nack.ResponderSize(8192)),
); err != nil {
logger.Error("failed to register interceptor", zap.Error(err))
panic(err)
}
api := webrtc.NewAPI(
webrtc.WithMediaEngine(mediaEngine),
webrtc.WithSettingEngine(settingEngine),
webrtc.WithInterceptorRegistry(&interseporRegistry),
)
ctrl := &controller{
api: api,
logger: logger,
roomRepo: roomRepo,
}
go func() { // Enviar solicitud RTCP I-frame cada dos segundos
ticker := time.NewTicker(2 * time.Second)
for _ = range ticker.C {
roomIds := ctrl.roomRepo.GetRooms()
for _, roomId := range roomIds {
go ctrl.dispatch(roomId)
}
}
}()
return ctrl
}
Emulación de red con pérdida de paquetes:
sudo tc qdisc add dev lo root netem delay 50ms 20ms loss 1%
Cliente Qt usando libdatachannel
El navegador sirve como fuente de video a través de un servidor HTTP mínimo. El cliente C++ usa libdatachannel + Qt sin necesidad de Chromium completo.
Establecer conexión peer:
void ConferenceClient::connectClient(QString url, QString roomId)
{
rtc::InitLogger(rtc::LogLevel::Debug);
this->pc.onLocalDescription(this->pcOnLocalDescription(roomId));
this->pc.onLocalCandidate(this->pcOnLocalCandidate());
this->pc.onGatheringStateChange(this->pcOnGatheringStateChange());
this->pc.onIceStateChange( {
std::cout << "Ice state changed: " << state << std::endl;
});
this->pc.onStateChange( {
std::cout << "state changed: " << state << std::endl;
});
this->ws.onOpen(this->wsOnOpen(roomId));
this->ws.onMessage(this->wsOnMessage());
this->pc.onTrack(this->pcOnTrack());
this->ws.open(url.toStdString());
}
Manejo de tracks y paquetes
Para cada track, creamos una estructura con un buffer de jitter (LRUCache) y una cola de frames:
std::function<void(std::shared_ptr<rtc::Track>)> ConferenceClient::pcOnTrack() {
return this {
auto mid = track->description().mid();
this->track_index[mid]
= {track, 0, "NO_VALUE", 0, 0, LRUCache<std::uint32_t, jitterbuffer>(256)};
this->player->initMid(mid);
bool isVideo = true;
if (track->description().type() == "audio") {
isVideo = false;
track->setMediaHandler(std::make_shared<rtc::OpusRtpDepacketizer>());
track->chainMediaHandler(std::make_shared<rtc::RtcpReceivingSession>());
track->onFrame(this->trackOnFrame(mid, isVideo));
} else {
track->onMessage(this->pcOnMessage(mid));
}
track->onOpen([track]() { track->requestKeyframe(); });
track->onClosed([this, mid]() { this->player->destroy(mid); });
};
}
Aspectos clave del manejo de paquetes RTP:
- Descartar paquetes tardíos (rtpHeader->timestamp() < lastCompletedTs)
- Retransmisión RTX – restaurar seqNumber y payloadType originales
- Orden de bytes – los datos RTP son big-endian; la plataforma es little-endian
- Cola de frames como std::map<uint32_t, pair<long, vector<byte>>> indexada por timestamp RTP
- Reproducción tras PLAYER_DELAY tras la llegada del primer paquete de frame
Reconstrucción de frames VP8/VP9
std::function<void(rtc::message_variant)> ConferenceClient::pcOnMessage(std::string mid)
{
return this, mid {
// ... verificación de timestamp, procesamiento RTX ...
std::vector<std::byte> frame;
if (!frame_cache.exist(pkgTs)
&& (track->description().rtpMap(PT)->format == MyApp::VP8CODEC
|| track->description().rtpMap(PT)->format == MyApp::VP9CODEC)) {
frame_cache.put(pkgTs, jitterbuffer());
track_info.ssrc = rtpHeader->ssrc();
track_info.frame_queue[pkgTs] = std::make_pair(nowTs, std::vector<std::byte>());
codec = track->description().rtpMap(PT)->format;
codecPT = PT;
}
jitterbuffer &buff = frame_cache.get(pkgTs);
if (codec == MyApp::VP9CODEC) {
frame = buff.addVp9Packet(std::move(msg), track_info.lastCompletedTs);
} else if (codec == MyApp::VP8CODEC) {
frame = buff.addVp8Packet(std::move(msg), track_info.lastCompletedTs);
}
if (frame.size() > 0) {
track_info.frame_queue[pkgTs].second = std::move(frame);
}
// ... reproducción y NACK ...
};
}
Conclusiones clave
- libdatachannel requiere implementación manual del buffer de jitter y NACK
- Los interceptores de Pion manejan automáticamente el NACK con tamaños de buffer establecidos en 8192
- La cola de frames usa std::map para orden natural por timestamp RTP
- Las asignaciones RTX se negocian en el intercambio SDP offer/answer
- La reproducción ocurre tras PLAYER_DELAY tras la llegada del primer frame
- Se envía PLI RTCP periódicamente cada 2 segundos
— Editorial Team
Aún no hay comentarios.