Files
livekit/pkg/sfu/connectionquality/connectionstats.go
Raja Subramanian 2cf751d261 Use timer in scorer lock scope. (#2066)
Using time from outside make anachronous samples in expected
distance/bit rate measurement. So, have to let the time be
snap shotted in scorer lock scope.
2023-09-13 01:38:34 +05:30

459 lines
12 KiB
Go

// Copyright 2023 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package connectionquality
import (
"strings"
"sync"
"time"
"github.com/frostbyte73/core"
"github.com/pion/webrtc/v3"
"go.uber.org/atomic"
"github.com/livekit/livekit-server/pkg/sfu/buffer"
"github.com/livekit/protocol/livekit"
"github.com/livekit/protocol/logger"
)
const (
UpdateInterval = 5 * time.Second
noReceiverReportTooLongThreshold = 30 * time.Second
)
type ConnectionStatsReceiverProvider interface {
GetDeltaStats() map[uint32]*buffer.StreamStatsWithLayers
}
type ConnectionStatsSenderProvider interface {
GetDeltaStatsSender() map[uint32]*buffer.StreamStatsWithLayers
GetLastReceiverReportTime() time.Time
GetTotalPacketsSent() uint64
}
type ConnectionStatsParams struct {
UpdateInterval time.Duration
MimeType string
IsFECEnabled bool
IncludeRTT bool
IncludeJitter bool
ReceiverProvider ConnectionStatsReceiverProvider
SenderProvider ConnectionStatsSenderProvider
Logger logger.Logger
}
type ConnectionStats struct {
params ConnectionStatsParams
isStarted atomic.Bool
isVideo atomic.Bool
onStatsUpdate func(cs *ConnectionStats, stat *livekit.AnalyticsStat)
lock sync.RWMutex
packetsSent uint64
streamingStartedAt time.Time
scorer *qualityScorer
done core.Fuse
}
func NewConnectionStats(params ConnectionStatsParams) *ConnectionStats {
return &ConnectionStats{
params: params,
scorer: newQualityScorer(qualityScorerParams{
PacketLossWeight: getPacketLossWeight(params.MimeType, params.IsFECEnabled), // LK-TODO: have to notify codec change?
IncludeRTT: params.IncludeRTT,
IncludeJitter: params.IncludeJitter,
Logger: params.Logger,
}),
done: core.NewFuse(),
}
}
func (cs *ConnectionStats) start(trackInfo *livekit.TrackInfo) {
cs.isVideo.Store(trackInfo.Type == livekit.TrackType_VIDEO)
go cs.updateStatsWorker()
}
func (cs *ConnectionStats) StartAt(trackInfo *livekit.TrackInfo, at time.Time) {
if cs.isStarted.Swap(true) {
return
}
cs.scorer.StartAt(at)
cs.start(trackInfo)
}
func (cs *ConnectionStats) Start(trackInfo *livekit.TrackInfo) {
if cs.isStarted.Swap(true) {
return
}
cs.scorer.Start()
cs.start(trackInfo)
}
func (cs *ConnectionStats) Close() {
cs.done.Break()
}
func (cs *ConnectionStats) OnStatsUpdate(fn func(cs *ConnectionStats, stat *livekit.AnalyticsStat)) {
cs.onStatsUpdate = fn
}
func (cs *ConnectionStats) UpdateMuteAt(isMuted bool, at time.Time) {
if cs.done.IsBroken() {
return
}
cs.scorer.UpdateMuteAt(isMuted, at)
}
func (cs *ConnectionStats) UpdateMute(isMuted bool) {
if cs.done.IsBroken() {
return
}
cs.scorer.UpdateMute(isMuted)
}
func (cs *ConnectionStats) AddBitrateTransitionAt(bitrate int64, at time.Time) {
if cs.done.IsBroken() {
return
}
cs.scorer.AddBitrateTransitionAt(bitrate, at)
}
func (cs *ConnectionStats) AddBitrateTransition(bitrate int64) {
if cs.done.IsBroken() {
return
}
cs.scorer.AddBitrateTransition(bitrate)
}
func (cs *ConnectionStats) UpdateLayerMuteAt(isMuted bool, at time.Time) {
if cs.done.IsBroken() {
return
}
cs.scorer.UpdateLayerMuteAt(isMuted, at)
}
func (cs *ConnectionStats) UpdateLayerMute(isMuted bool) {
if cs.done.IsBroken() {
return
}
cs.scorer.UpdateLayerMute(isMuted)
}
func (cs *ConnectionStats) UpdatePauseAt(isPaused bool, at time.Time) {
if cs.done.IsBroken() {
return
}
cs.scorer.UpdatePauseAt(isPaused, at)
}
func (cs *ConnectionStats) UpdatePause(isPaused bool) {
if cs.done.IsBroken() {
return
}
cs.scorer.UpdatePause(isPaused)
}
func (cs *ConnectionStats) AddLayerTransitionAt(distance float64, at time.Time) {
if cs.done.IsBroken() {
return
}
cs.scorer.AddLayerTransitionAt(distance, at)
}
func (cs *ConnectionStats) AddLayerTransition(distance float64) {
if cs.done.IsBroken() {
return
}
cs.scorer.AddLayerTransition(distance)
}
func (cs *ConnectionStats) GetScoreAndQuality() (float32, livekit.ConnectionQuality) {
return cs.scorer.GetMOSAndQuality()
}
func (cs *ConnectionStats) updateScoreWithAggregate(agg *buffer.RTPDeltaInfo, at time.Time) float32 {
var stat windowStat
if agg != nil {
stat.startedAt = agg.StartTime
stat.duration = agg.Duration
stat.packetsExpected = agg.Packets + agg.PacketsPadding
stat.packetsLost = agg.PacketsLost
stat.packetsMissing = agg.PacketsMissing
stat.packetsOutOfOrder = agg.PacketsOutOfOrder
stat.bytes = agg.Bytes - agg.HeaderBytes // only use media payload size
stat.rttMax = agg.RttMax
stat.jitterMax = agg.JitterMax
}
if at.IsZero() {
cs.scorer.Update(&stat)
} else {
cs.scorer.UpdateAt(&stat, at)
}
mos, _ := cs.scorer.GetMOSAndQuality()
return mos
}
func (cs *ConnectionStats) updateScoreFromReceiverReport(at time.Time) (float32, map[uint32]*buffer.StreamStatsWithLayers) {
if cs.params.SenderProvider == nil {
return MinMOS, nil
}
streamingStartedAt := cs.updateStreamingStart(at)
if streamingStartedAt.IsZero() {
// not streaming, just return current score
mos, _ := cs.scorer.GetMOSAndQuality()
return mos, nil
}
streams := cs.params.SenderProvider.GetDeltaStatsSender()
if len(streams) == 0 {
// check for receiver report not received for a while
marker := cs.params.SenderProvider.GetLastReceiverReportTime()
if marker.IsZero() || streamingStartedAt.After(marker) {
marker = streamingStartedAt
}
if time.Since(marker) > noReceiverReportTooLongThreshold {
// have not received receiver report for a long time when streaming, run with nil stat
return cs.updateScoreWithAggregate(nil, at), nil
}
// wait for receiver report, return current score
mos, _ := cs.scorer.GetMOSAndQuality()
return mos, nil
}
// delta stat duration could be large due to not receiving receiver report for a long time (for example, due to mute),
// adjust to streaming start if necessary
agg := toAggregateDeltaInfo(streams)
if streamingStartedAt.After(cs.params.SenderProvider.GetLastReceiverReportTime()) {
// last receiver report was before streaming started, wait for next one
mos, _ := cs.scorer.GetMOSAndQuality()
return mos, streams
}
if streamingStartedAt.After(agg.StartTime) {
agg.Duration = agg.StartTime.Add(agg.Duration).Sub(streamingStartedAt)
agg.StartTime = streamingStartedAt
}
return cs.updateScoreWithAggregate(agg, at), streams
}
func (cs *ConnectionStats) updateScoreAt(at time.Time) (float32, map[uint32]*buffer.StreamStatsWithLayers) {
if cs.params.SenderProvider != nil {
// receiver report based quality scoring, use stats from receiver report for scoring
return cs.updateScoreFromReceiverReport(at)
}
if cs.params.ReceiverProvider == nil {
return MinMOS, nil
}
streams := cs.params.ReceiverProvider.GetDeltaStats()
if len(streams) == 0 {
mos, _ := cs.scorer.GetMOSAndQuality()
return mos, nil
}
deltaInfoList := make([]*buffer.RTPDeltaInfo, 0, len(streams))
for _, s := range streams {
deltaInfoList = append(deltaInfoList, s.RTPStats)
}
agg := buffer.AggregateRTPDeltaInfo(deltaInfoList)
return cs.updateScoreWithAggregate(agg, at), streams
}
func (cs *ConnectionStats) updateStreamingStart(at time.Time) time.Time {
cs.lock.Lock()
defer cs.lock.Unlock()
packetsSent := cs.params.SenderProvider.GetTotalPacketsSent()
if packetsSent > cs.packetsSent {
if cs.streamingStartedAt.IsZero() {
// the start could be anywhere after last update, but using `at` as this is not required to be accurate
if at.IsZero() {
cs.streamingStartedAt = time.Now()
} else {
cs.streamingStartedAt = at
}
}
} else {
cs.streamingStartedAt = time.Time{}
}
cs.packetsSent = packetsSent
return cs.streamingStartedAt
}
func (cs *ConnectionStats) getStat() {
score, streams := cs.updateScoreAt(time.Time{})
if cs.onStatsUpdate != nil && len(streams) != 0 {
analyticsStreams := make([]*livekit.AnalyticsStream, 0, len(streams))
for ssrc, stream := range streams {
as := toAnalyticsStream(ssrc, stream.RTPStats)
//
// add video layer if either
// 1. Simulcast - even if there is only one layer per stream as it provides layer id
// 2. A stream has multiple layers
//
if (len(streams) > 1 || len(stream.Layers) > 1) && cs.isVideo.Load() {
for layer, layerStats := range stream.Layers {
avl := toAnalyticsVideoLayer(layer, layerStats)
if avl != nil {
as.VideoLayers = append(as.VideoLayers, avl)
}
}
}
analyticsStreams = append(analyticsStreams, as)
}
cs.onStatsUpdate(cs, &livekit.AnalyticsStat{
Score: score,
Streams: analyticsStreams,
Mime: cs.params.MimeType,
})
}
}
func (cs *ConnectionStats) updateStatsWorker() {
interval := cs.params.UpdateInterval
if interval == 0 {
interval = UpdateInterval
}
tk := time.NewTicker(interval)
defer tk.Stop()
for {
select {
case <-cs.done.Watch():
return
case <-tk.C:
if cs.done.IsBroken() {
return
}
cs.getStat()
}
}
}
// -----------------------------------------------------------------------
// how much weight to give to packet loss rate when calculating score.
// It is codec dependent.
// For audio:
//
// o Opus without FEC or RED suffers the most through packet loss, hence has the highest weight
// o RED with two packet redundancy can absorb two out of every three packets lost, so packet loss is not as detrimental and therefore lower weight
//
// For video:
//
// o No in-built codec repair available, hence same for all codecs
func getPacketLossWeight(mimeType string, isFecEnabled bool) float64 {
var plw float64
switch {
case strings.EqualFold(mimeType, webrtc.MimeTypeOpus):
// 2.5%: fall to GOOD, 7.5%: fall to POOR
plw = 8.0
if isFecEnabled {
// 3.75%: fall to GOOD, 11.25%: fall to POOR
plw /= 1.5
}
case strings.EqualFold(mimeType, "audio/red"):
// 10%: fall to GOOD, 30.0%: fall to POOR
plw = 2.0
if isFecEnabled {
// 15%: fall to GOOD, 45.0%: fall to POOR
plw /= 1.5
}
case strings.HasPrefix(strings.ToLower(mimeType), "video/"):
// 2%: fall to GOOD, 6%: fall to POOR
plw = 10.0
}
return plw
}
func toAggregateDeltaInfo(streams map[uint32]*buffer.StreamStatsWithLayers) *buffer.RTPDeltaInfo {
deltaInfoList := make([]*buffer.RTPDeltaInfo, 0, len(streams))
for _, s := range streams {
deltaInfoList = append(deltaInfoList, s.RTPStats)
}
return buffer.AggregateRTPDeltaInfo(deltaInfoList)
}
func toAnalyticsStream(ssrc uint32, deltaStats *buffer.RTPDeltaInfo) *livekit.AnalyticsStream {
// discount the feed side loss when reporting forwarded track stats
packetsLost := deltaStats.PacketsLost
if deltaStats.PacketsMissing > packetsLost {
packetsLost = 0
} else {
packetsLost -= deltaStats.PacketsMissing
}
return &livekit.AnalyticsStream{
Ssrc: ssrc,
PrimaryPackets: deltaStats.Packets,
PrimaryBytes: deltaStats.Bytes,
RetransmitPackets: deltaStats.PacketsDuplicate,
RetransmitBytes: deltaStats.BytesDuplicate,
PaddingPackets: deltaStats.PacketsPadding,
PaddingBytes: deltaStats.BytesPadding,
PacketsLost: packetsLost,
Frames: deltaStats.Frames,
Rtt: deltaStats.RttMax,
Jitter: uint32(deltaStats.JitterMax),
Nacks: deltaStats.Nacks,
Plis: deltaStats.Plis,
Firs: deltaStats.Firs,
}
}
func toAnalyticsVideoLayer(layer int32, layerStats *buffer.RTPDeltaInfo) *livekit.AnalyticsVideoLayer {
avl := &livekit.AnalyticsVideoLayer{
Layer: layer,
Packets: layerStats.Packets + layerStats.PacketsDuplicate + layerStats.PacketsPadding,
Bytes: layerStats.Bytes + layerStats.BytesDuplicate + layerStats.BytesPadding,
Frames: layerStats.Frames,
}
if avl.Packets == 0 || avl.Bytes == 0 || avl.Frames == 0 {
return nil
}
return avl
}