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 :
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 :
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
Aucun commentaire pour le moment.