Retour à l'accueil

Vidéo WebRTC dans Qt : tampon de gigue + NACK

L'article décrit l'implémentation d'un client vidéo WebRTC sur C++/Qt utilisant libdatachannel. Analyse détaillée du tampon de gigue, du mécanisme NACK, de l'assemblage des trames VP8/VP9 et du serveur Pion avec intercepteurs.

WebRTC dans Qt : pile complète avec NACK et tampon de gigue
Advertisement 728x90

Mettre en œuvre la vidéo WebRTC avec tampon de jitter et NACK dans un client Qt

WebRTC utilise UDP pour réduire la charge de transmission. Les clients échangent des descriptions SDP, utilisent STUN/TURN pour contourner les NAT, ICE pour maintenir les connexions, et un serveur de signalisation pour la coordination. Les serveurs SFU/MCU agissent comme des passerelles pour le transfert de flux. Un flux VP8 1080p@30fps nécessite environ 600 Kbps sans images clés. La perte de paquets augmente considérablement la charge via les demandes PLI et NACK. Le tampon de jitter résout les problèmes d'ordre des paquets.

Tâches essentielles : serveur de signalisation/MCU avec prise en charge du NACK, mécanisme de retransmission, tampon de jitter côté client.

Serveur Pion avec prise en charge du NACK

Le serveur est implémenté en Go à l'aide de la bibliothèque Pion. Nous enregistrons des intercepteurs par défaut pour le 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() { // Envoyer une demande I-frame RTCP toutes les deux secondes
		ticker := time.NewTicker(2 * time.Second)
		for _ = range ticker.C {
			roomIds := ctrl.roomRepo.GetRooms()
			for _, roomId := range roomIds {
				go ctrl.dispatch(roomId)
			}
		}
	}()

	return ctrl
}

Émulation réseau avec perte de paquets :

sudo tc qdisc add dev lo root netem delay 50ms 20ms loss 1%

Client Qt utilisant libdatachannel

Le navigateur sert de source vidéo via un serveur HTTP minimal. Le client C++ utilise libdatachannel + Qt sans nécessiter Chromium complet.

Établissement de la connexion pair à pair :

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());
}

Gestion des pistes et des paquets

Pour chaque piste, nous créons une structure comprenant un tampon de jitter (LRUCache) et une file d'attente de trames :

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); });
    };
}

Aspects clés de la gestion des paquets RTP :

  • Suppression des paquets tardifs (rtpHeader->timestamp() < lastCompletedTs)
  • Retransmission RTX – restauration du seqNumber original et du payloadType
  • Ordre des octets – les données RTP sont big-endian ; la plateforme est little-endian
  • File d'attente de trames sous forme de std::map<uint32_t, pair<long, vector<byte>>> indexée par timestamp RTP
  • Lecture après PLAYER_DELAY suivant l'arrivée du premier paquet de trame

Reconstruction des trames VP8/VP9

std::function<void(rtc::message_variant)> ConferenceClient::pcOnMessage(std::string mid)
{
    return this, mid {
        // ... vérification du timestamp, traitement 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);
        }
        // ... lecture et NACK ...
    };
}

Points clés

  • libdatachannel nécessite une implémentation manuelle du tampon de jitter et du NACK
  • Les intercepteurs Pion gèrent automatiquement le NACK avec des tailles de tampon fixées à 8192
  • La file d'attente de trames utilise std::map pour un ordre naturel basé sur le timestamp RTP
  • Les mappages RTX sont négociés dans l'échange SDP
  • La lecture a lieu après PLAYER_DELAY suivant l'arrivée de la première trame
  • Des PLI RTCP sont envoyés périodiquement toutes les 2 secondes

— Editorial Team

Google AdInline article slot
Advertisement 728x90

Lire ensuite