홈으로 돌아가기

Qt에서의 WebRTC 비디오: 지터 버퍼 + NACK

이 기사는 libdatachannel을 사용한 C++/Qt에서의 WebRTC 비디오 클라이언트 구현을 설명합니다. 지터 버퍼, NACK 메커니즘, VP8/VP9 프레임 조립 및 인터셉터가 있는 Pion 서버에 대한 상세 분석.

Qt에서의 WebRTC: NACK 및 지터 버퍼를 포함한 풀 스택
Advertisement 728x90

Qt 클라이언트에서 Jitter Buffer와 NACK을 활용한 WebRTC 영상 구현

WebRTC는 전송 오버헤드를 최소화하기 위해 UDP를 사용합니다. 클라이언트는 SDP 설명서를 교환하고, NAT 통과를 위해 STUN/TURN를 활용하며, 연결 유지에는 ICE를 사용하고, 조율을 위해 신호 서버를 운영합니다. SFU/MCU 서버는 스트림 전달을 위한 중계 장치 역할을 합니다. 1080p@30fps의 VP8 스트림은 키프레임 없이 약 600 Kbps의 대역폭을 필요로 합니다. 패킷 손실은 PLI 요청과 NACK을 통해 부하를 급격히 증가시킵니다. 지터 버퍼는 패킷 순서 문제를 해결합니다.

주요 작업: NACK 지원이 있는 신호/ MCU 서버, 재전송 메커니즘, 클라이언트 측 지터 버퍼 구현.

Pion 서버에 NACK 지원 추가

서버는 Go 언어로 Pion 라이브러리를 사용해 구현되었습니다. 기본 인터셉터를 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() { // 2초마다 RTCP 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
}

패킷 손실 시뮬레이션:

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

libdatachannel을 사용한 Qt 클라이언트

브라우저는 최소 HTTP 서버를 통해 영상 소스 역할을 합니다. C++ 클라이언트는 전체 크롬 없이 libdatachannel + Qt만으로 동작합니다.

피어 연결 설정:

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

트랙 및 패킷 처리

각 트랙마다 지터 버퍼(LRUCache)와 프레임 큐를 포함하는 구조체를 생성합니다:

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

RTP 패킷 처리의 핵심 요소:

  • 지연된 패킷 무시 (rtpHeader->timestamp() < lastCompletedTs)
  • RTX 재전송 – 원본 seqNumber 및 payloadType 복원
  • 엔디안 처리 – RTP 데이터는 빅엔디안, 플랫폼은 리틀엔디안
  • 프레임 큐는 RTP 타임스탬프를 키로 사용하는 std::map<uint32_t, pair<long, vector<byte>>>
  • 재생은 첫 번째 프레임 수신 후 PLAYER_DELAY 이후 시작

VP8/VP9 프레임 재구성

std::function<void(rtc::message_variant)> ConferenceClient::pcOnMessage(std::string mid)
{
    return this, mid {
        // ... 타임스탬프 검사, 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);
        }
        // ... 재생 및 NACK 처리 ...
    };
}

핵심 정리

  • libdatachannel은 지터 버퍼와 NACK를 수동으로 구현해야 함
  • Pion 인터셉터는 버퍼 크기를 8192로 설정해 자동으로 NACK 처리
  • 프레임 큐는 RTP 타임스탬프 기준 자연스러운 순서를 위해 std::map 사용
  • RTX 매핑은 SDP 오퍼/응답 과정에서 협상됨
  • 재생은 첫 번째 프레임 수신 후 PLAYER_DELAY 이후 시작
  • 주기적인 RTCP PLI는 2초마다 전송됨

— Editorial Team

Google AdInline article slot
Advertisement 728x90

다음 읽기