Powrót do strony głównej

WebRTC wideo w Qt: jitter buffer + NACK

Artykuł opisuje realizację klienta wideo WebRTC na C++/Qt z wykorzystaniem libdatachannel. Szczegółowy rozkład jitter buffer, mechanizmu NACK, składania klatek VP8/VP9 i serwera Pion z interceptorami.

WebRTC w Qt: pełny stos z NACK i jitter buffer
Advertisement 728x90

Implementacja WebRTC z buforem jitter i NACK w kliencie Qt

WebRTC wykorzystuje UDP, aby minimalizować narzuty przy przesyłaniu wideo. Klienci wymieniają opisy SDP, używają STUN/TURN do przejścia przez NAT, ICE do utrzymania połączeń oraz serwer sygnalizacyjny do koordynacji. SFU/MCU działają jako middleboxy do transmisji strumieni. Strumień 1080p@30fps w formacie VP8 wymaga około 600 KB/s bez klatek referencyjnych. Przegrane pakiety powodują gwałtowne wzrost obciążenia dzięki żądaniami PLI i mechanizmowi NACK. Bufor jitter rozwiązuje problem nieuporządkowanych pakietów.

Zadania: serwer sygnalizacyjny/MCU z obsługą NACK, mechanizm retransmisji, klientowski bufor jitter.

Serwer na Pionie z obsługą NACK

Serwer jest realizowany w języku Go z biblioteką Pion. Rejestrujemy domyślne interceptorzy dla 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()
	interceptorRegistry := interceptor.Registry{}

	if err := webrtc.RegisterDefaultInterceptorsWithOptions(mediaEngine, &interceptorRegistry,
		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(&interceptorRegistry),
	)

	ctrl := &controller{
		api:      api,
		logger:   logger,
		roomRepo: roomRepo,
	}

	go func() { // co dwie sekundy wysyłamy RTCP z prośbą o I-frame
		ticker := time.NewTicker(2 * time.Second)
		for range ticker.C {
			roomIds := ctrl.roomRepo.GetRooms()
			for _, roomId := range roomIds {
				go ctrl.dispatch(roomId)
			}
		}
	}()

	return ctrl
}

Symulacja sieci z utratą pakietów:

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

Klient Qt z libdatachannel

Przeglądarka działa jako źródło wideo przez prosty serwer HTTP. Klient C++ używa libdatachannel + Qt bez pełnego Chromium.

Połączenie peer connection:

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([](auto state) {
        std::cout << "Ice state changed: " << state << std::endl;
    });
    this->pc.onStateChange([](auto state) {
        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());
}

Obsługa ścieżek i pakietów

Dla każdej ścieżki tworzona jest struktura z buforem jitter (LRUCache) i kolejką klatek:

std::function<void(std::shared_ptr<rtc::Track>)> ConferenceClient::pcOnTrack() {
    return [this](std::shared_ptr<rtc::Track> track) {
        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); });
    };
}

Kluczowe cechy obsługi pakietów RTP:

  • Odrzucanie opóźnionych pakietów (rtpHeader->timestamp() < lastCompletedTs)
  • Retransmisja RTX – odzyskanie oryginalnego seqNumber i payloadType
  • Endianness – dane RTP w formacie big-endian, platforma little-endian
  • Kolejka klatek jako std::map<uint32_t, pair<long, vector<byte>>> według timestampu RTP
  • Odtwarzanie po opóźnieniu PLAYER_DELAY

Łączenie klatek VP8/VP9

std::function<void(rtc::message_variant)> ConferenceClient::pcOnMessage(std::string mid)
{
    return [this, mid](rtc::message_variant msg) {
        // ... sprawdzenie timestampu, obsługa 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);
        }
        // ... odtwarzanie i NACK ...
    };
}

Co jest ważne

  • libdatachannel wymaga ręcznej implementacji buforu jitter i NACK
  • Interceptorzy Pion automatycznie obsługują NACK z ustawieniem buforów 8192
  • Kolejka klatek używa std::map dla naturalnego porządku według timestampu RTP
  • Mapowanie RTX ustala się w ofercie/odpowiedzi SDP
  • Odtwarzanie odbywa się po opóźnieniu PLAYER_DELAY po przybyciu pierwszego pakietu klatki
  • Okresowe RTCP PLI co 2 sekundy

— Editorial Team

Google AdInline article slot
Advertisement 728x90

Czytaj dalej