Volver al inicio

Vídeo WebRTC en Qt: buffer de jitter + NACK

El artículo describe la implementación de cliente de vídeo WebRTC en C++/Qt usando libdatachannel. Análisis detallado del buffer de jitter, mecanismo NACK, ensamblaje de frames VP8/VP9 y servidor Pion con interceptores.

WebRTC en Qt: pila completa con NACK y buffer de jitter
Advertisement 728x90

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:

Google AdInline article slot
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:

Google AdInline article slot
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

Google AdInline article slot
Advertisement 728x90

Leer después