WebRTC-Video mit Jitter Buffer und NACK in einem Qt-Client implementieren
WebRTC nutzt UDP, um die Übertragungsbelastung zu minimieren. Clients tauschen SDP-Descriptionen aus, verwenden STUN/TURN für die NAT-Durchquerung, ICE zur Verbindungspflege und einen Signaling-Server zur Koordination. SFU/MCU-Server fungieren als Middleboxes für den Stream-Forwarding. Ein 1080p@30fps-VP8-Stream benötigt ohne Keyframes etwa 600 Kbps. Paketverlust erhöht die Last erheblich durch PLI-Anfragen und NACK. Der Jitter Buffer löst Probleme mit der Paketreihenfolge.
Wichtige Aufgaben: Signaling/MCU-Server mit NACK-Unterstützung, Wiederholungsmechanismus, Client-seitiger Jitter Buffer.
Pion-Server mit NACK-Unterstützung
Der Server wird in Go mit der Pion-Bibliothek implementiert. Wir registrieren Standard-Interceptoren für 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() { // Alle zwei Sekunden RTCP I-Frame-Anfrage senden
ticker := time.NewTicker(2 * time.Second)
for _ = range ticker.C {
roomIds := ctrl.roomRepo.GetRooms()
for _, roomId := range roomIds {
go ctrl.dispatch(roomId)
}
}
}()
return ctrl
}
Netzwerkemulation mit Paketverlust:
sudo tc qdisc add dev lo root netem delay 50ms 20ms loss 1%
Qt-Client mit libdatachannel
Der Browser dient als Videoquelle über einen minimalen HTTP-Server. Der C++-Client verwendet libdatachannel + Qt, ohne vollständiges Chromium zu benötigen.
Herstellen der Peer-Verbindung:
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());
}
Behandlung von Tracks und Paketen
Für jeden Track erstellen wir eine Struktur mit einem Jitter Buffer (LRUCache) und einer Frame-Warteschlange:
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); });
};
}
Wichtige Aspekte der RTP-Paketbehandlung:
- Späte Pakete verwerfen (rtpHeader->timestamp() < lastCompletedTs)
- RTX-Wiederholung – Wiederherstellung der ursprünglichen seqNumber und payloadType
- Endianness – RTP-Daten sind big-endian; Plattform ist little-endian
- Frame-Warteschlange als std::map<uint32_t, pair<long, vector<byte>>> indexiert nach RTP-Timestamp
- Wiedergabe nach PLAYER_DELAY ab Erhalt des ersten Frame-Pakets
Reassemblierung von VP8/VP9-Frames
std::function<void(rtc::message_variant)> ConferenceClient::pcOnMessage(std::string mid)
{
return this, mid {
// ... Timestamp-Prüfung, RTX-Verarbeitung ...
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);
}
// ... Wiedergabe und NACK ...
};
}
Wichtige Erkenntnisse
- libdatachannel erfordert manuelle Implementierung von Jitter Buffer und NACK
- Pion-Interceptoren verarbeiten NACK automatisch mit Puffergrößen von 8192
- Frame-Warteschlange nutzt std::map für natürliche Sortierung nach RTP-Timestamp
- RTX-Zuordnungen werden im SDP-Offer/Antwort vereinbart
- Wiedergabe erfolgt nach PLAYER_DELAY nach Erhalt des ersten Frame-Pakets
- Periodische RTCP-PLI wird alle 2 Sekunden gesendet
— Editorial Team
Noch keine Kommentare.