Squashed 'livekit-server/' content from commit 154b4d26
git-subtree-dir: livekit-server git-subtree-split: 154b4d26b769c68a03c096124094b97bf61a996f
This commit is contained in:
@@ -0,0 +1,499 @@
|
||||
// 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 (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/frostbyte73/core"
|
||||
"go.uber.org/atomic"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/sfu/buffer"
|
||||
"github.com/livekit/livekit-server/pkg/sfu/mime"
|
||||
"github.com/livekit/livekit-server/pkg/sfu/rtpstats"
|
||||
)
|
||||
|
||||
const (
|
||||
UpdateInterval = 5 * time.Second
|
||||
noReceiverReportTooLongThreshold = 30 * time.Second
|
||||
)
|
||||
|
||||
type ConnectionStatsReceiverProvider interface {
|
||||
GetDeltaStats() map[uint32]*buffer.StreamStatsWithLayers
|
||||
GetLastSenderReportTime() time.Time
|
||||
}
|
||||
|
||||
type ConnectionStatsSenderProvider interface {
|
||||
GetDeltaStatsSender() map[uint32]*buffer.StreamStatsWithLayers
|
||||
GetPrimaryStreamLastReceiverReportTime() time.Time
|
||||
GetPrimaryStreamPacketsSent() uint64
|
||||
}
|
||||
|
||||
type ConnectionStatsParams struct {
|
||||
UpdateInterval time.Duration
|
||||
IncludeRTT bool
|
||||
IncludeJitter bool
|
||||
EnableBitrateScore bool
|
||||
ReceiverProvider ConnectionStatsReceiverProvider
|
||||
SenderProvider ConnectionStatsSenderProvider
|
||||
Logger logger.Logger
|
||||
}
|
||||
|
||||
type ConnectionStats struct {
|
||||
params ConnectionStatsParams
|
||||
|
||||
codecMimeType atomic.Value // mime.MimeType
|
||||
|
||||
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{
|
||||
IncludeRTT: params.IncludeRTT,
|
||||
IncludeJitter: params.IncludeJitter,
|
||||
EnableBitrateScore: params.EnableBitrateScore,
|
||||
Logger: params.Logger,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *ConnectionStats) StartAt(codecMimeType mime.MimeType, isFECEnabled bool, at time.Time) {
|
||||
if cs.isStarted.Swap(true) {
|
||||
return
|
||||
}
|
||||
|
||||
cs.isVideo.Store(mime.IsMimeTypeVideo(codecMimeType))
|
||||
cs.codecMimeType.Store(codecMimeType)
|
||||
cs.scorer.StartAt(getPacketLossWeight(codecMimeType, isFECEnabled), at)
|
||||
|
||||
go cs.updateStatsWorker()
|
||||
}
|
||||
|
||||
func (cs *ConnectionStats) Start(codecMimeType mime.MimeType, isFECEnabled bool) {
|
||||
cs.StartAt(codecMimeType, isFECEnabled, time.Now())
|
||||
}
|
||||
|
||||
func (cs *ConnectionStats) Close() {
|
||||
cs.done.Break()
|
||||
}
|
||||
|
||||
func (cs *ConnectionStats) UpdateCodec(codecMimeType mime.MimeType, isFECEnabled bool) {
|
||||
cs.isVideo.Store(mime.IsMimeTypeVideo(codecMimeType))
|
||||
cs.codecMimeType.Store(codecMimeType)
|
||||
cs.scorer.UpdatePacketLossWeight(getPacketLossWeight(codecMimeType, isFECEnabled))
|
||||
}
|
||||
|
||||
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 *rtpstats.RTPDeltaInfo, lastRTCPAt time.Time, at time.Time) float32 {
|
||||
var stat windowStat
|
||||
if agg != nil {
|
||||
stat.startedAt = agg.StartTime
|
||||
stat.duration = agg.EndTime.Sub(agg.StartTime)
|
||||
stat.packets = agg.Packets
|
||||
stat.packetsPadding = 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
|
||||
|
||||
stat.lastRTCPAt = lastRTCPAt
|
||||
}
|
||||
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.GetPrimaryStreamLastReceiverReportTime()
|
||||
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, time.Time{}, 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
|
||||
if streamingStartedAt.After(cs.params.SenderProvider.GetPrimaryStreamLastReceiverReportTime()) {
|
||||
// last receiver report was before streaming started, wait for next one
|
||||
mos, _ := cs.scorer.GetMOSAndQuality()
|
||||
return mos, streams
|
||||
}
|
||||
|
||||
agg := toAggregateDeltaInfo(streams, true)
|
||||
if agg == nil {
|
||||
// no receiver report in the window
|
||||
mos, _ := cs.scorer.GetMOSAndQuality()
|
||||
return mos, streams
|
||||
}
|
||||
if streamingStartedAt.After(agg.StartTime) {
|
||||
agg.StartTime = streamingStartedAt
|
||||
}
|
||||
return cs.updateScoreWithAggregate(agg, time.Time{}, 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
|
||||
}
|
||||
|
||||
agg := toAggregateDeltaInfo(streams, false)
|
||||
if agg == nil {
|
||||
// no receiver report in the window
|
||||
mos, _ := cs.scorer.GetMOSAndQuality()
|
||||
return mos, streams
|
||||
}
|
||||
return cs.updateScoreWithAggregate(agg, cs.params.ReceiverProvider.GetLastSenderReportTime(), at), streams
|
||||
}
|
||||
|
||||
func (cs *ConnectionStats) updateStreamingStart(at time.Time) time.Time {
|
||||
cs.lock.Lock()
|
||||
defer cs.lock.Unlock()
|
||||
|
||||
packetsSent := cs.params.SenderProvider.GetPrimaryStreamPacketsSent()
|
||||
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, stream.RTPStatsRemoteView)
|
||||
if as == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
|
||||
if len(analyticsStreams) != 0 {
|
||||
cs.onStatsUpdate(cs, &livekit.AnalyticsStat{
|
||||
Score: score,
|
||||
Streams: analyticsStreams,
|
||||
Mime: cs.codecMimeType.Load().(mime.MimeType).String(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 one out of every two 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 mime.MimeType, isFecEnabled bool) float64 {
|
||||
var plw float64
|
||||
switch {
|
||||
case mimeType == mime.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 mimeType == mime.MimeTypeRED:
|
||||
// 5%: fall to GOOD, 15.0%: fall to POOR
|
||||
plw = 4.0
|
||||
if isFecEnabled {
|
||||
// 7.5%: fall to GOOD, 22.5%: fall to POOR
|
||||
plw /= 1.5
|
||||
}
|
||||
|
||||
case mime.IsMimeTypeVideo(mimeType):
|
||||
// 2%: fall to GOOD, 6%: fall to POOR
|
||||
plw = 10.0
|
||||
}
|
||||
|
||||
return plw
|
||||
}
|
||||
|
||||
func toAggregateDeltaInfo(streams map[uint32]*buffer.StreamStatsWithLayers, useRemoteView bool) *rtpstats.RTPDeltaInfo {
|
||||
deltaInfoList := make([]*rtpstats.RTPDeltaInfo, 0, len(streams))
|
||||
for _, s := range streams {
|
||||
if useRemoteView {
|
||||
if s.RTPStatsRemoteView != nil {
|
||||
deltaInfoList = append(deltaInfoList, s.RTPStatsRemoteView)
|
||||
}
|
||||
} else {
|
||||
if s.RTPStats != nil {
|
||||
deltaInfoList = append(deltaInfoList, s.RTPStats)
|
||||
}
|
||||
}
|
||||
}
|
||||
return rtpstats.AggregateRTPDeltaInfo(deltaInfoList)
|
||||
}
|
||||
|
||||
func toAnalyticsStream(
|
||||
ssrc uint32,
|
||||
deltaStats *rtpstats.RTPDeltaInfo,
|
||||
deltaStatsRemoteView *rtpstats.RTPDeltaInfo,
|
||||
) *livekit.AnalyticsStream {
|
||||
if deltaStats == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// discount the feed side loss when reporting forwarded track stats,
|
||||
packetsLost := deltaStats.PacketsLost
|
||||
if deltaStatsRemoteView != nil {
|
||||
packetsLost = deltaStatsRemoteView.PacketsLost
|
||||
if deltaStatsRemoteView.PacketsMissing > packetsLost {
|
||||
packetsLost = 0
|
||||
} else {
|
||||
packetsLost -= deltaStatsRemoteView.PacketsMissing
|
||||
}
|
||||
}
|
||||
return &livekit.AnalyticsStream{
|
||||
StartTime: timestamppb.New(deltaStats.StartTime),
|
||||
EndTime: timestamppb.New(deltaStats.EndTime),
|
||||
Ssrc: ssrc,
|
||||
PrimaryPackets: deltaStats.Packets,
|
||||
PrimaryBytes: deltaStats.Bytes,
|
||||
RetransmitPackets: deltaStats.PacketsDuplicate,
|
||||
RetransmitBytes: deltaStats.BytesDuplicate,
|
||||
PaddingPackets: deltaStats.PacketsPadding,
|
||||
PaddingBytes: deltaStats.BytesPadding,
|
||||
PacketsLost: packetsLost,
|
||||
PacketsOutOfOrder: deltaStats.PacketsOutOfOrder,
|
||||
Frames: deltaStats.Frames,
|
||||
Rtt: deltaStats.RttMax,
|
||||
Jitter: uint32(deltaStats.JitterMax),
|
||||
Nacks: deltaStats.Nacks,
|
||||
Plis: deltaStats.Plis,
|
||||
Firs: deltaStats.Firs,
|
||||
}
|
||||
}
|
||||
|
||||
func toAnalyticsVideoLayer(layer int32, layerStats *rtpstats.RTPDeltaInfo) *livekit.AnalyticsVideoLayer {
|
||||
if layerStats == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,903 @@
|
||||
// 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 (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/sfu/buffer"
|
||||
"github.com/livekit/livekit-server/pkg/sfu/mime"
|
||||
"github.com/livekit/livekit-server/pkg/sfu/rtpstats"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
)
|
||||
|
||||
// -----------------------------------------------
|
||||
|
||||
type testReceiverProvider struct {
|
||||
streams map[uint32]*buffer.StreamStatsWithLayers
|
||||
lastSenderReportTime time.Time
|
||||
}
|
||||
|
||||
func newTestReceiverProvider() *testReceiverProvider {
|
||||
return &testReceiverProvider{}
|
||||
}
|
||||
|
||||
func (trp *testReceiverProvider) setStreams(streams map[uint32]*buffer.StreamStatsWithLayers) {
|
||||
trp.streams = streams
|
||||
}
|
||||
|
||||
func (trp *testReceiverProvider) GetDeltaStats() map[uint32]*buffer.StreamStatsWithLayers {
|
||||
return trp.streams
|
||||
}
|
||||
|
||||
func (trp *testReceiverProvider) setLastSenderReportTime(at time.Time) {
|
||||
trp.lastSenderReportTime = at
|
||||
}
|
||||
|
||||
func (trp *testReceiverProvider) GetLastSenderReportTime() time.Time {
|
||||
return trp.lastSenderReportTime
|
||||
}
|
||||
|
||||
// -----------------------------------------------
|
||||
|
||||
func TestConnectionQuality(t *testing.T) {
|
||||
trp := newTestReceiverProvider()
|
||||
t.Run("quality scorer operation", func(t *testing.T) {
|
||||
cs := NewConnectionStats(ConnectionStatsParams{
|
||||
IncludeRTT: true,
|
||||
IncludeJitter: true,
|
||||
EnableBitrateScore: true,
|
||||
ReceiverProvider: trp,
|
||||
Logger: logger.GetLogger(),
|
||||
})
|
||||
|
||||
duration := 5 * time.Second
|
||||
now := time.Now()
|
||||
cs.StartAt(mime.MimeTypeOpus, false, now.Add(-duration))
|
||||
cs.UpdateMuteAt(false, now.Add(-1*time.Second))
|
||||
|
||||
// no data and not enough unmute time should return default state which is EXCELLENT quality
|
||||
cs.updateScoreAt(now)
|
||||
mos, quality := cs.GetScoreAndQuality()
|
||||
require.Greater(t, float32(4.6), mos)
|
||||
require.Equal(t, livekit.ConnectionQuality_EXCELLENT, quality)
|
||||
|
||||
// best conditions (no loss, jitter/rtt = 0) - quality should stay EXCELLENT
|
||||
trp.setStreams(map[uint32]*buffer.StreamStatsWithLayers{
|
||||
1: {
|
||||
RTPStats: &rtpstats.RTPDeltaInfo{
|
||||
StartTime: now,
|
||||
EndTime: now.Add(duration),
|
||||
Packets: 250,
|
||||
},
|
||||
},
|
||||
})
|
||||
cs.updateScoreAt(now.Add(duration))
|
||||
mos, quality = cs.GetScoreAndQuality()
|
||||
require.Greater(t, float32(4.6), mos)
|
||||
require.Equal(t, livekit.ConnectionQuality_EXCELLENT, quality)
|
||||
|
||||
// introduce loss and the score should drop - 12% loss for Opus -> POOR
|
||||
now = now.Add(duration)
|
||||
trp.setStreams(map[uint32]*buffer.StreamStatsWithLayers{
|
||||
1: {
|
||||
RTPStats: &rtpstats.RTPDeltaInfo{
|
||||
StartTime: now,
|
||||
EndTime: now.Add(duration),
|
||||
Packets: 120,
|
||||
PacketsLost: 30,
|
||||
},
|
||||
},
|
||||
2: {
|
||||
RTPStats: &rtpstats.RTPDeltaInfo{
|
||||
StartTime: now,
|
||||
EndTime: now.Add(duration),
|
||||
Packets: 130,
|
||||
PacketsLost: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
cs.updateScoreAt(now.Add(duration))
|
||||
mos, quality = cs.GetScoreAndQuality()
|
||||
require.Greater(t, float32(2.1), mos)
|
||||
require.Equal(t, livekit.ConnectionQuality_POOR, quality)
|
||||
|
||||
// should climb to GOOD quality in one iteration if the conditions improve.
|
||||
// although significant loss (12%) in the previous window, lowest score is
|
||||
// bound so that climbing back does not take too long even under excellent conditions.
|
||||
now = now.Add(duration)
|
||||
trp.setStreams(map[uint32]*buffer.StreamStatsWithLayers{
|
||||
1: {
|
||||
RTPStats: &rtpstats.RTPDeltaInfo{
|
||||
StartTime: now,
|
||||
EndTime: now.Add(duration),
|
||||
Packets: 250,
|
||||
},
|
||||
},
|
||||
})
|
||||
cs.updateScoreAt(now.Add(duration))
|
||||
mos, quality = cs.GetScoreAndQuality()
|
||||
require.Greater(t, float32(4.1), mos)
|
||||
require.Equal(t, livekit.ConnectionQuality_GOOD, quality)
|
||||
|
||||
// should stay at GOOD if conditions continue to be good
|
||||
now = now.Add(duration)
|
||||
trp.setStreams(map[uint32]*buffer.StreamStatsWithLayers{
|
||||
1: {
|
||||
RTPStats: &rtpstats.RTPDeltaInfo{
|
||||
StartTime: now,
|
||||
EndTime: now.Add(duration),
|
||||
Packets: 250,
|
||||
},
|
||||
},
|
||||
})
|
||||
cs.updateScoreAt(now.Add(duration))
|
||||
mos, quality = cs.GetScoreAndQuality()
|
||||
require.Greater(t, float32(4.1), mos)
|
||||
require.Equal(t, livekit.ConnectionQuality_GOOD, quality)
|
||||
|
||||
// should climb up to EXCELLENT if conditions continue to be good
|
||||
now = now.Add(duration)
|
||||
trp.setStreams(map[uint32]*buffer.StreamStatsWithLayers{
|
||||
1: {
|
||||
RTPStats: &rtpstats.RTPDeltaInfo{
|
||||
StartTime: now,
|
||||
EndTime: now.Add(duration),
|
||||
Packets: 250,
|
||||
},
|
||||
},
|
||||
})
|
||||
cs.updateScoreAt(now.Add(duration))
|
||||
mos, quality = cs.GetScoreAndQuality()
|
||||
require.Greater(t, float32(4.6), mos)
|
||||
require.Equal(t, livekit.ConnectionQuality_EXCELLENT, quality)
|
||||
|
||||
// introduce loss and the score should drop - 5% loss for Opus -> GOOD
|
||||
now = now.Add(duration)
|
||||
trp.setStreams(map[uint32]*buffer.StreamStatsWithLayers{
|
||||
1: {
|
||||
RTPStats: &rtpstats.RTPDeltaInfo{
|
||||
StartTime: now,
|
||||
EndTime: now.Add(duration),
|
||||
Packets: 250,
|
||||
PacketsLost: 13,
|
||||
},
|
||||
},
|
||||
})
|
||||
cs.updateScoreAt(now.Add(duration))
|
||||
mos, quality = cs.GetScoreAndQuality()
|
||||
require.Greater(t, float32(4.1), mos)
|
||||
require.Equal(t, livekit.ConnectionQuality_GOOD, quality)
|
||||
|
||||
// should stay at GOOD quality for another iteration even if the conditions improve
|
||||
now = now.Add(duration)
|
||||
trp.setStreams(map[uint32]*buffer.StreamStatsWithLayers{
|
||||
1: {
|
||||
RTPStats: &rtpstats.RTPDeltaInfo{
|
||||
StartTime: now,
|
||||
EndTime: now.Add(duration),
|
||||
Packets: 250,
|
||||
},
|
||||
},
|
||||
})
|
||||
cs.updateScoreAt(now.Add(duration))
|
||||
mos, quality = cs.GetScoreAndQuality()
|
||||
require.Greater(t, float32(4.1), mos)
|
||||
require.Equal(t, livekit.ConnectionQuality_GOOD, quality)
|
||||
|
||||
// should climb up to EXCELLENT if conditions continue to be good
|
||||
now = now.Add(duration)
|
||||
trp.setStreams(map[uint32]*buffer.StreamStatsWithLayers{
|
||||
1: {
|
||||
RTPStats: &rtpstats.RTPDeltaInfo{
|
||||
StartTime: now,
|
||||
EndTime: now.Add(duration),
|
||||
Packets: 250,
|
||||
},
|
||||
},
|
||||
})
|
||||
cs.updateScoreAt(now.Add(duration))
|
||||
mos, quality = cs.GetScoreAndQuality()
|
||||
require.Greater(t, float32(4.6), mos)
|
||||
require.Equal(t, livekit.ConnectionQuality_EXCELLENT, quality)
|
||||
|
||||
// mute when quality is POOR should return quality to EXCELLENT
|
||||
now = now.Add(duration)
|
||||
trp.setStreams(map[uint32]*buffer.StreamStatsWithLayers{
|
||||
1: {
|
||||
RTPStats: &rtpstats.RTPDeltaInfo{
|
||||
StartTime: now,
|
||||
EndTime: now.Add(duration),
|
||||
Packets: 250,
|
||||
PacketsLost: 30,
|
||||
},
|
||||
},
|
||||
})
|
||||
cs.updateScoreAt(now.Add(duration))
|
||||
mos, quality = cs.GetScoreAndQuality()
|
||||
require.Greater(t, float32(2.1), mos)
|
||||
require.Equal(t, livekit.ConnectionQuality_POOR, quality)
|
||||
|
||||
now = now.Add(duration)
|
||||
cs.UpdateMuteAt(true, now.Add(1*time.Second))
|
||||
mos, quality = cs.GetScoreAndQuality()
|
||||
require.Greater(t, float32(4.6), mos)
|
||||
require.Equal(t, livekit.ConnectionQuality_EXCELLENT, quality)
|
||||
|
||||
// unmute at specific time to ensure next window does not satisfy the unmute time threshold.
|
||||
// that means even if the next update has 0 packets, it should hold state and stay at EXCELLENT quality
|
||||
cs.UpdateMuteAt(false, now.Add(3*time.Second))
|
||||
|
||||
trp.setStreams(map[uint32]*buffer.StreamStatsWithLayers{
|
||||
1: {
|
||||
RTPStats: &rtpstats.RTPDeltaInfo{
|
||||
StartTime: now,
|
||||
EndTime: now.Add(duration),
|
||||
Packets: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
cs.updateScoreAt(now.Add(duration))
|
||||
mos, quality = cs.GetScoreAndQuality()
|
||||
require.Greater(t, float32(4.6), mos)
|
||||
require.Equal(t, livekit.ConnectionQuality_EXCELLENT, quality)
|
||||
|
||||
// next update with no packets,
|
||||
// but last RTCP is not set, should knock quality down to POOR
|
||||
now = now.Add(duration)
|
||||
trp.setStreams(map[uint32]*buffer.StreamStatsWithLayers{
|
||||
1: {
|
||||
RTPStats: &rtpstats.RTPDeltaInfo{
|
||||
StartTime: now,
|
||||
EndTime: now.Add(duration),
|
||||
Packets: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
cs.updateScoreAt(now.Add(duration))
|
||||
mos, quality = cs.GetScoreAndQuality()
|
||||
require.Greater(t, float32(2.1), mos)
|
||||
require.Equal(t, livekit.ConnectionQuality_POOR, quality)
|
||||
|
||||
// another dry spell, but last RTCP is not stale, should keep quality at POOR
|
||||
now = now.Add(duration)
|
||||
trp.setLastSenderReportTime(now.Add(time.Second))
|
||||
trp.setStreams(map[uint32]*buffer.StreamStatsWithLayers{
|
||||
1: {
|
||||
RTPStats: &rtpstats.RTPDeltaInfo{
|
||||
StartTime: now,
|
||||
EndTime: now.Add(duration),
|
||||
Packets: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
cs.updateScoreAt(now.Add(duration))
|
||||
mos, quality = cs.GetScoreAndQuality()
|
||||
require.Greater(t, float32(2.1), mos)
|
||||
require.Equal(t, livekit.ConnectionQuality_POOR, quality)
|
||||
|
||||
// yet another dry spell, but last RTCP is stale, should knock down quality at LOST
|
||||
now = now.Add(duration)
|
||||
trp.setStreams(map[uint32]*buffer.StreamStatsWithLayers{
|
||||
1: {
|
||||
RTPStats: &rtpstats.RTPDeltaInfo{
|
||||
StartTime: now,
|
||||
EndTime: now.Add(duration),
|
||||
Packets: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
cs.updateScoreAt(now.Add(duration))
|
||||
mos, quality = cs.GetScoreAndQuality()
|
||||
require.Greater(t, float32(1.3), mos)
|
||||
require.Equal(t, livekit.ConnectionQuality_LOST, quality)
|
||||
|
||||
// mute when LOST should not bump up score/quality
|
||||
now = now.Add(duration)
|
||||
cs.UpdateMuteAt(true, now.Add(1*time.Second))
|
||||
mos, quality = cs.GetScoreAndQuality()
|
||||
require.Greater(t, float32(1.3), mos)
|
||||
require.Equal(t, livekit.ConnectionQuality_LOST, quality)
|
||||
|
||||
// unmute and send packets to bring quality back up
|
||||
now = now.Add(duration)
|
||||
cs.UpdateMuteAt(false, now.Add(2*time.Second))
|
||||
for i := 0; i < 3; i++ {
|
||||
trp.setStreams(map[uint32]*buffer.StreamStatsWithLayers{
|
||||
1: {
|
||||
RTPStats: &rtpstats.RTPDeltaInfo{
|
||||
StartTime: now,
|
||||
EndTime: now.Add(duration),
|
||||
Packets: 250,
|
||||
PacketsLost: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
cs.updateScoreAt(now.Add(duration))
|
||||
now = now.Add(duration)
|
||||
}
|
||||
cs.updateScoreAt(now.Add(duration))
|
||||
mos, quality = cs.GetScoreAndQuality()
|
||||
require.Greater(t, float32(4.6), mos)
|
||||
require.Equal(t, livekit.ConnectionQuality_EXCELLENT, quality)
|
||||
|
||||
// with lesser number of packet (simulating DTX).
|
||||
// even higher loss (like 10%) should not knock down quality due to quadratic weighting of packet loss ratio
|
||||
trp.setStreams(map[uint32]*buffer.StreamStatsWithLayers{
|
||||
1: {
|
||||
RTPStats: &rtpstats.RTPDeltaInfo{
|
||||
StartTime: now,
|
||||
EndTime: now.Add(duration),
|
||||
Packets: 50,
|
||||
PacketsLost: 5,
|
||||
},
|
||||
},
|
||||
})
|
||||
cs.updateScoreAt(now.Add(duration))
|
||||
mos, quality = cs.GetScoreAndQuality()
|
||||
require.Greater(t, float32(4.6), mos)
|
||||
require.Equal(t, livekit.ConnectionQuality_EXCELLENT, quality)
|
||||
|
||||
// mute/unmute to bring quality back up
|
||||
now = now.Add(duration)
|
||||
cs.UpdateMuteAt(true, now.Add(1*time.Second))
|
||||
cs.UpdateMuteAt(false, now.Add(2*time.Second))
|
||||
|
||||
// RTT and jitter can knock quality down.
|
||||
// at 2% loss, quality should stay at EXCELLENT purely based on loss, but with added RTT/jitter, should drop to GOOD
|
||||
trp.setStreams(map[uint32]*buffer.StreamStatsWithLayers{
|
||||
1: {
|
||||
RTPStats: &rtpstats.RTPDeltaInfo{
|
||||
StartTime: now,
|
||||
EndTime: now.Add(duration),
|
||||
Packets: 250,
|
||||
PacketsLost: 5,
|
||||
RttMax: 400,
|
||||
JitterMax: 30000,
|
||||
},
|
||||
},
|
||||
})
|
||||
cs.updateScoreAt(now.Add(duration))
|
||||
mos, quality = cs.GetScoreAndQuality()
|
||||
require.Greater(t, float32(4.1), mos)
|
||||
require.Equal(t, livekit.ConnectionQuality_GOOD, quality)
|
||||
|
||||
// mute/unmute to bring quality back up
|
||||
now = now.Add(duration)
|
||||
cs.UpdateMuteAt(true, now.Add(1*time.Second))
|
||||
cs.UpdateMuteAt(false, now.Add(2*time.Second))
|
||||
|
||||
// bitrate based calculation can drop quality even if there is no loss
|
||||
cs.AddBitrateTransitionAt(1_000_000, now)
|
||||
cs.AddBitrateTransitionAt(2_000_000, now.Add(2*time.Second))
|
||||
|
||||
trp.setStreams(map[uint32]*buffer.StreamStatsWithLayers{
|
||||
1: {
|
||||
RTPStats: &rtpstats.RTPDeltaInfo{
|
||||
StartTime: now,
|
||||
EndTime: now.Add(duration),
|
||||
Packets: 250,
|
||||
Bytes: 8_000_000 / 8 / 5,
|
||||
},
|
||||
},
|
||||
})
|
||||
cs.updateScoreAt(now.Add(duration))
|
||||
mos, quality = cs.GetScoreAndQuality()
|
||||
require.Greater(t, float32(4.1), mos)
|
||||
require.Equal(t, livekit.ConnectionQuality_GOOD, quality)
|
||||
|
||||
// test layer mute via UpdateLayerMute API
|
||||
cs.AddBitrateTransitionAt(1_000_000, now)
|
||||
cs.AddBitrateTransitionAt(2_000_000, now.Add(2*time.Second))
|
||||
|
||||
trp.setStreams(map[uint32]*buffer.StreamStatsWithLayers{
|
||||
1: {
|
||||
RTPStats: &rtpstats.RTPDeltaInfo{
|
||||
StartTime: now,
|
||||
EndTime: now.Add(duration),
|
||||
Packets: 250,
|
||||
Bytes: 8_000_000 / 8 / 5,
|
||||
},
|
||||
},
|
||||
})
|
||||
cs.updateScoreAt(now.Add(duration))
|
||||
mos, quality = cs.GetScoreAndQuality()
|
||||
require.Greater(t, float32(4.1), mos)
|
||||
require.Equal(t, livekit.ConnectionQuality_GOOD, quality)
|
||||
|
||||
now = now.Add(duration)
|
||||
cs.UpdateLayerMuteAt(true, now)
|
||||
mos, quality = cs.GetScoreAndQuality()
|
||||
require.Greater(t, float32(4.6), mos)
|
||||
require.Equal(t, livekit.ConnectionQuality_EXCELLENT, quality)
|
||||
|
||||
// unmute layer
|
||||
cs.UpdateLayerMuteAt(false, now.Add(2*time.Second))
|
||||
|
||||
trp.setStreams(map[uint32]*buffer.StreamStatsWithLayers{
|
||||
1: {
|
||||
RTPStats: &rtpstats.RTPDeltaInfo{
|
||||
StartTime: now,
|
||||
EndTime: now.Add(duration),
|
||||
Packets: 250,
|
||||
Bytes: 8_000_000 / 8 / 5,
|
||||
},
|
||||
},
|
||||
})
|
||||
cs.updateScoreAt(now.Add(duration))
|
||||
mos, quality = cs.GetScoreAndQuality()
|
||||
require.Greater(t, float32(4.6), mos)
|
||||
require.Equal(t, livekit.ConnectionQuality_EXCELLENT, quality)
|
||||
|
||||
// pause
|
||||
now = now.Add(duration)
|
||||
cs.UpdatePauseAt(true, now)
|
||||
mos, quality = cs.GetScoreAndQuality()
|
||||
require.Greater(t, float32(2.1), mos)
|
||||
require.Equal(t, livekit.ConnectionQuality_POOR, quality)
|
||||
|
||||
// resume
|
||||
cs.UpdatePauseAt(false, now.Add(2*time.Second))
|
||||
|
||||
// although conditions are perfect, climbing back from POOR (because of pause above)
|
||||
// will only climb to GOOD.
|
||||
trp.setStreams(map[uint32]*buffer.StreamStatsWithLayers{
|
||||
1: {
|
||||
RTPStats: &rtpstats.RTPDeltaInfo{
|
||||
StartTime: now,
|
||||
EndTime: now.Add(duration),
|
||||
Packets: 250,
|
||||
Bytes: 8_000_000 / 8 / 5,
|
||||
},
|
||||
},
|
||||
})
|
||||
cs.updateScoreAt(now.Add(duration))
|
||||
mos, quality = cs.GetScoreAndQuality()
|
||||
require.Greater(t, float32(4.1), mos)
|
||||
require.Equal(t, livekit.ConnectionQuality_GOOD, quality)
|
||||
})
|
||||
|
||||
t.Run("quality scorer dependent rtt", func(t *testing.T) {
|
||||
cs := NewConnectionStats(ConnectionStatsParams{
|
||||
IncludeRTT: false,
|
||||
IncludeJitter: true,
|
||||
ReceiverProvider: trp,
|
||||
Logger: logger.GetLogger(),
|
||||
})
|
||||
|
||||
duration := 5 * time.Second
|
||||
now := time.Now()
|
||||
cs.StartAt(mime.MimeTypeOpus, false, now.Add(-duration))
|
||||
cs.UpdateMuteAt(false, now.Add(-1*time.Second))
|
||||
|
||||
// RTT does not knock quality down because it is dependent and hence not taken into account
|
||||
// at 2% loss, quality should stay at EXCELLENT purely based on loss. With high RTT (700 ms)
|
||||
// quality should drop to GOOD if RTT were taken into consideration
|
||||
trp.setStreams(map[uint32]*buffer.StreamStatsWithLayers{
|
||||
1: {
|
||||
RTPStats: &rtpstats.RTPDeltaInfo{
|
||||
StartTime: now,
|
||||
EndTime: now.Add(duration),
|
||||
Packets: 250,
|
||||
PacketsLost: 5,
|
||||
RttMax: 700,
|
||||
},
|
||||
},
|
||||
})
|
||||
cs.updateScoreAt(now.Add(duration))
|
||||
mos, quality := cs.GetScoreAndQuality()
|
||||
require.Greater(t, float32(4.6), mos)
|
||||
require.Equal(t, livekit.ConnectionQuality_EXCELLENT, quality)
|
||||
})
|
||||
|
||||
t.Run("quality scorer dependent jitter", func(t *testing.T) {
|
||||
cs := NewConnectionStats(ConnectionStatsParams{
|
||||
IncludeRTT: true,
|
||||
IncludeJitter: false,
|
||||
ReceiverProvider: trp,
|
||||
Logger: logger.GetLogger(),
|
||||
})
|
||||
|
||||
duration := 5 * time.Second
|
||||
now := time.Now()
|
||||
cs.StartAt(mime.MimeTypeOpus, false, now.Add(-duration))
|
||||
cs.UpdateMuteAt(false, now.Add(-1*time.Second))
|
||||
|
||||
// Jitter does not knock quality down because it is dependent and hence not taken into account
|
||||
// at 2% loss, quality should stay at EXCELLENT purely based on loss. With high jitter (200 ms)
|
||||
// quality should drop to GOOD if jitter were taken into consideration
|
||||
trp.setStreams(map[uint32]*buffer.StreamStatsWithLayers{
|
||||
1: {
|
||||
RTPStats: &rtpstats.RTPDeltaInfo{
|
||||
StartTime: now,
|
||||
EndTime: now.Add(duration),
|
||||
Packets: 250,
|
||||
PacketsLost: 5,
|
||||
JitterMax: 200,
|
||||
},
|
||||
},
|
||||
})
|
||||
cs.updateScoreAt(now.Add(duration))
|
||||
mos, quality := cs.GetScoreAndQuality()
|
||||
require.Greater(t, float32(4.6), mos)
|
||||
require.Equal(t, livekit.ConnectionQuality_EXCELLENT, quality)
|
||||
})
|
||||
|
||||
t.Run("codecs - packet", func(t *testing.T) {
|
||||
type expectedQuality struct {
|
||||
packetLossPercentage float64
|
||||
expectedMOS float32
|
||||
expectedQuality livekit.ConnectionQuality
|
||||
}
|
||||
testCases := []struct {
|
||||
name string
|
||||
mimeType mime.MimeType
|
||||
isFECEnabled bool
|
||||
packetsExpected uint32
|
||||
expectedQualities []expectedQuality
|
||||
}{
|
||||
// NOTE: Because of EWMA (Exponentially Weighted Moving Average), these cut off points are not exact
|
||||
// "audio/opus" - no fec - 0 <= loss < 2.5%: EXCELLENT, 2.5% <= loss < 7.5%: GOOD, >= 7.5%: POOR
|
||||
{
|
||||
name: "audio/opus - no fec",
|
||||
mimeType: mime.MimeTypeOpus,
|
||||
isFECEnabled: false,
|
||||
packetsExpected: 200,
|
||||
expectedQualities: []expectedQuality{
|
||||
{
|
||||
packetLossPercentage: 1.0,
|
||||
expectedMOS: 4.6,
|
||||
expectedQuality: livekit.ConnectionQuality_EXCELLENT,
|
||||
},
|
||||
{
|
||||
packetLossPercentage: 4.0,
|
||||
expectedMOS: 4.1,
|
||||
expectedQuality: livekit.ConnectionQuality_GOOD,
|
||||
},
|
||||
{
|
||||
packetLossPercentage: 9.2,
|
||||
expectedMOS: 2.1,
|
||||
expectedQuality: livekit.ConnectionQuality_POOR,
|
||||
},
|
||||
},
|
||||
},
|
||||
// "audio/opus" - fec - 0 <= loss < 3.75%: EXCELLENT, 3.75% <= loss < 11.25%: GOOD, >= 11.25%: POOR
|
||||
{
|
||||
name: "audio/opus - fec",
|
||||
mimeType: mime.MimeTypeOpus,
|
||||
isFECEnabled: true,
|
||||
packetsExpected: 200,
|
||||
expectedQualities: []expectedQuality{
|
||||
{
|
||||
packetLossPercentage: 3.0,
|
||||
expectedMOS: 4.6,
|
||||
expectedQuality: livekit.ConnectionQuality_EXCELLENT,
|
||||
},
|
||||
{
|
||||
packetLossPercentage: 4.4,
|
||||
expectedMOS: 4.1,
|
||||
expectedQuality: livekit.ConnectionQuality_GOOD,
|
||||
},
|
||||
{
|
||||
packetLossPercentage: 15.0,
|
||||
expectedMOS: 2.1,
|
||||
expectedQuality: livekit.ConnectionQuality_POOR,
|
||||
},
|
||||
},
|
||||
},
|
||||
// "audio/red" - no fec - 0 <= loss < 5%: EXCELLENT, 5% <= loss < 15%: GOOD, >= 15%: POOR
|
||||
{
|
||||
name: "audio/red - no fec",
|
||||
mimeType: mime.MimeTypeRED,
|
||||
isFECEnabled: false,
|
||||
packetsExpected: 200,
|
||||
expectedQualities: []expectedQuality{
|
||||
{
|
||||
packetLossPercentage: 4.0,
|
||||
expectedMOS: 4.6,
|
||||
expectedQuality: livekit.ConnectionQuality_EXCELLENT,
|
||||
},
|
||||
{
|
||||
packetLossPercentage: 6.0,
|
||||
expectedMOS: 4.1,
|
||||
expectedQuality: livekit.ConnectionQuality_GOOD,
|
||||
},
|
||||
{
|
||||
packetLossPercentage: 19.5,
|
||||
expectedMOS: 2.1,
|
||||
expectedQuality: livekit.ConnectionQuality_POOR,
|
||||
},
|
||||
},
|
||||
},
|
||||
// "audio/red" - fec - 0 <= loss < 7.5%: EXCELLENT, 7.5% <= loss < 22.5%: GOOD, >= 22.5%: POOR
|
||||
{
|
||||
name: "audio/red - fec",
|
||||
mimeType: mime.MimeTypeRED,
|
||||
isFECEnabled: true,
|
||||
packetsExpected: 200,
|
||||
expectedQualities: []expectedQuality{
|
||||
{
|
||||
packetLossPercentage: 6.0,
|
||||
expectedMOS: 4.6,
|
||||
expectedQuality: livekit.ConnectionQuality_EXCELLENT,
|
||||
},
|
||||
{
|
||||
packetLossPercentage: 10.0,
|
||||
expectedMOS: 4.1,
|
||||
expectedQuality: livekit.ConnectionQuality_GOOD,
|
||||
},
|
||||
{
|
||||
packetLossPercentage: 30.0,
|
||||
expectedMOS: 2.1,
|
||||
expectedQuality: livekit.ConnectionQuality_POOR,
|
||||
},
|
||||
},
|
||||
},
|
||||
// "video/*" - 0 <= loss < 2%: EXCELLENT, 2% <= loss < 6%: GOOD, >= 6%: POOR
|
||||
{
|
||||
name: "video/*",
|
||||
mimeType: mime.MimeTypeVP8,
|
||||
isFECEnabled: false,
|
||||
packetsExpected: 200,
|
||||
expectedQualities: []expectedQuality{
|
||||
{
|
||||
packetLossPercentage: 1.0,
|
||||
expectedMOS: 4.6,
|
||||
expectedQuality: livekit.ConnectionQuality_EXCELLENT,
|
||||
},
|
||||
{
|
||||
packetLossPercentage: 3.5,
|
||||
expectedMOS: 4.1,
|
||||
expectedQuality: livekit.ConnectionQuality_GOOD,
|
||||
},
|
||||
{
|
||||
packetLossPercentage: 8.0,
|
||||
expectedMOS: 2.1,
|
||||
expectedQuality: livekit.ConnectionQuality_POOR,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cs := NewConnectionStats(ConnectionStatsParams{
|
||||
IncludeRTT: true,
|
||||
IncludeJitter: true,
|
||||
ReceiverProvider: trp,
|
||||
Logger: logger.GetLogger(),
|
||||
})
|
||||
|
||||
duration := 5 * time.Second
|
||||
now := time.Now()
|
||||
cs.StartAt(tc.mimeType, tc.isFECEnabled, now.Add(-duration))
|
||||
|
||||
for _, eq := range tc.expectedQualities {
|
||||
trp.setStreams(map[uint32]*buffer.StreamStatsWithLayers{
|
||||
123: {
|
||||
RTPStats: &rtpstats.RTPDeltaInfo{
|
||||
StartTime: now,
|
||||
EndTime: now.Add(duration),
|
||||
Packets: tc.packetsExpected,
|
||||
PacketsLost: uint32(math.Ceil(eq.packetLossPercentage * float64(tc.packetsExpected) / 100.0)),
|
||||
},
|
||||
},
|
||||
})
|
||||
cs.updateScoreAt(now.Add(duration))
|
||||
mos, quality := cs.GetScoreAndQuality()
|
||||
require.Greater(t, eq.expectedMOS, mos)
|
||||
require.Equal(t, eq.expectedQuality, quality)
|
||||
|
||||
now = now.Add(duration)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bitrate", func(t *testing.T) {
|
||||
type transition struct {
|
||||
bitrate int64
|
||||
offset time.Duration
|
||||
}
|
||||
testCases := []struct {
|
||||
name string
|
||||
transitions []transition
|
||||
bytes uint64
|
||||
expectedMOS float32
|
||||
expectedQuality livekit.ConnectionQuality
|
||||
}{
|
||||
// NOTE: Because of EWMA (Exponentially Weighted Moving Average), these cut off points are not exact
|
||||
// 1.0 <= expectedBits / actualBits < ~2.7 = EXCELLENT
|
||||
// ~2.7 <= expectedBits / actualBits < ~20.1 = GOOD
|
||||
// expectedBits / actualBits >= ~20.1 = POOR
|
||||
{
|
||||
name: "excellent",
|
||||
transitions: []transition{
|
||||
{
|
||||
bitrate: 1_000_000,
|
||||
},
|
||||
{
|
||||
bitrate: 2_000_000,
|
||||
offset: 3 * time.Second,
|
||||
},
|
||||
},
|
||||
bytes: 6_000_000 / 8,
|
||||
expectedMOS: 4.6,
|
||||
expectedQuality: livekit.ConnectionQuality_EXCELLENT,
|
||||
},
|
||||
{
|
||||
name: "good",
|
||||
transitions: []transition{
|
||||
{
|
||||
bitrate: 1_000_000,
|
||||
},
|
||||
{
|
||||
bitrate: 2_000_000,
|
||||
offset: 3 * time.Second,
|
||||
},
|
||||
},
|
||||
bytes: uint64(math.Ceil(7_000_000.0 / 8.0 / 4.2)),
|
||||
expectedMOS: 4.1,
|
||||
expectedQuality: livekit.ConnectionQuality_GOOD,
|
||||
},
|
||||
{
|
||||
name: "poor",
|
||||
transitions: []transition{
|
||||
{
|
||||
bitrate: 2_000_000,
|
||||
},
|
||||
{
|
||||
bitrate: 1_000_000,
|
||||
offset: 3 * time.Second,
|
||||
},
|
||||
},
|
||||
bytes: uint64(math.Ceil(8_000_000.0 / 8.0 / 75.0)),
|
||||
expectedMOS: 2.1,
|
||||
expectedQuality: livekit.ConnectionQuality_POOR,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cs := NewConnectionStats(ConnectionStatsParams{
|
||||
IncludeRTT: true,
|
||||
IncludeJitter: true,
|
||||
EnableBitrateScore: true,
|
||||
ReceiverProvider: trp,
|
||||
Logger: logger.GetLogger(),
|
||||
})
|
||||
|
||||
duration := 5 * time.Second
|
||||
now := time.Now()
|
||||
cs.StartAt(mime.MimeTypeVP8, false, now)
|
||||
|
||||
for _, tr := range tc.transitions {
|
||||
cs.AddBitrateTransitionAt(tr.bitrate, now.Add(tr.offset))
|
||||
}
|
||||
|
||||
trp.setStreams(map[uint32]*buffer.StreamStatsWithLayers{
|
||||
123: {
|
||||
RTPStats: &rtpstats.RTPDeltaInfo{
|
||||
StartTime: now,
|
||||
EndTime: now.Add(duration),
|
||||
Packets: 100,
|
||||
Bytes: tc.bytes,
|
||||
},
|
||||
},
|
||||
})
|
||||
cs.updateScoreAt(now.Add(duration))
|
||||
mos, quality := cs.GetScoreAndQuality()
|
||||
require.Greater(t, tc.expectedMOS, mos)
|
||||
require.Equal(t, tc.expectedQuality, quality)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("layer", func(t *testing.T) {
|
||||
type transition struct {
|
||||
distance float64
|
||||
offset time.Duration
|
||||
}
|
||||
testCases := []struct {
|
||||
name string
|
||||
transitions []transition
|
||||
expectedMOS float32
|
||||
expectedQuality livekit.ConnectionQuality
|
||||
}{
|
||||
// NOTE: Because of EWMA (Exponentially Weighted Moving Average), these cut off points are not exact
|
||||
// each spatial layer missed drops o quality level
|
||||
{
|
||||
name: "excellent",
|
||||
transitions: []transition{
|
||||
{
|
||||
distance: 0.5,
|
||||
},
|
||||
{
|
||||
distance: 0.0,
|
||||
offset: 3 * time.Second,
|
||||
},
|
||||
},
|
||||
expectedMOS: 4.6,
|
||||
expectedQuality: livekit.ConnectionQuality_EXCELLENT,
|
||||
},
|
||||
{
|
||||
name: "good",
|
||||
transitions: []transition{
|
||||
{
|
||||
distance: 1.0,
|
||||
},
|
||||
{
|
||||
distance: 1.5,
|
||||
offset: 2 * time.Second,
|
||||
},
|
||||
},
|
||||
expectedMOS: 4.1,
|
||||
expectedQuality: livekit.ConnectionQuality_GOOD,
|
||||
},
|
||||
{
|
||||
name: "poor",
|
||||
transitions: []transition{
|
||||
{
|
||||
distance: 2.0,
|
||||
},
|
||||
{
|
||||
distance: 2.6,
|
||||
offset: 1 * time.Second,
|
||||
},
|
||||
},
|
||||
expectedMOS: 2.1,
|
||||
expectedQuality: livekit.ConnectionQuality_POOR,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cs := NewConnectionStats(ConnectionStatsParams{
|
||||
IncludeRTT: true,
|
||||
IncludeJitter: true,
|
||||
ReceiverProvider: trp,
|
||||
Logger: logger.GetLogger(),
|
||||
})
|
||||
|
||||
duration := 5 * time.Second
|
||||
now := time.Now()
|
||||
cs.StartAt(mime.MimeTypeVP8, false, now)
|
||||
|
||||
for _, tr := range tc.transitions {
|
||||
cs.AddLayerTransitionAt(tr.distance, now.Add(tr.offset))
|
||||
}
|
||||
|
||||
trp.setStreams(map[uint32]*buffer.StreamStatsWithLayers{
|
||||
123: {
|
||||
RTPStats: &rtpstats.RTPDeltaInfo{
|
||||
StartTime: now,
|
||||
EndTime: now.Add(duration),
|
||||
Packets: 200,
|
||||
},
|
||||
},
|
||||
})
|
||||
cs.updateScoreAt(now.Add(duration))
|
||||
mos, quality := cs.GetScoreAndQuality()
|
||||
require.Greater(t, tc.expectedMOS, mos)
|
||||
require.Equal(t, tc.expectedQuality, quality)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,644 @@
|
||||
// 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 (
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/utils"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxMOS = float32(4.5)
|
||||
MinMOS = float32(1.0)
|
||||
|
||||
cMaxScore = float64(100.0)
|
||||
cMinScore = float64(30.0)
|
||||
|
||||
cIncreaseFactor = float64(0.4) // slower increase, i. e. when score is recovering move up slower -> conservative
|
||||
cDecreaseFactor = float64(0.8) // faster decrease, i. e. when score is dropping move down faster -> aggressive to be responsive to quality drops
|
||||
|
||||
cDistanceWeight = float64(35.0) // each spatial layer missed drops a quality level
|
||||
|
||||
cUnmuteTimeThreshold = float64(0.5)
|
||||
|
||||
cPPSQuantization = float64(2)
|
||||
cPPSMinReadings = 10
|
||||
cModeCalculationInterval = 2 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
qualityTransitionScore = map[livekit.ConnectionQuality]float64{
|
||||
livekit.ConnectionQuality_GOOD: 80,
|
||||
livekit.ConnectionQuality_POOR: 40,
|
||||
livekit.ConnectionQuality_LOST: 20,
|
||||
}
|
||||
)
|
||||
|
||||
// ------------------------------------------
|
||||
|
||||
type windowStat struct {
|
||||
startedAt time.Time
|
||||
duration time.Duration
|
||||
packets uint32
|
||||
packetsPadding uint32
|
||||
packetsLost uint32
|
||||
packetsMissing uint32
|
||||
packetsOutOfOrder uint32
|
||||
bytes uint64
|
||||
rttMax uint32
|
||||
jitterMax float64
|
||||
lastRTCPAt time.Time
|
||||
}
|
||||
|
||||
func (w *windowStat) calculatePacketScore(aplw float64, includeRTT bool, includeJitter bool) float64 {
|
||||
// this is based on simplified E-model based on packet loss, rtt, jitter as
|
||||
// outlined at https://www.pingman.com/kb/article/how-is-mos-calculated-in-pingplotter-pro-50.html.
|
||||
effectiveDelay := 0.0
|
||||
// discount the dependent factors if dependency indicated.
|
||||
// for example,
|
||||
// 1. in the up stream, RTT cannot be measured without RTCP-XR, it is using down stream RTT.
|
||||
// 2. in the down stream, up stream jitter affects it. although jitter can be adjusted to account for up stream
|
||||
// jitter, this lever can be used to discount jitter in scoring.
|
||||
if includeRTT {
|
||||
effectiveDelay += float64(w.rttMax) / 2.0
|
||||
}
|
||||
if includeJitter {
|
||||
effectiveDelay += (w.jitterMax * 2.0) / 1000.0
|
||||
}
|
||||
delayEffect := effectiveDelay / 40.0
|
||||
if effectiveDelay > 160.0 {
|
||||
delayEffect = (effectiveDelay - 120.0) / 10.0
|
||||
}
|
||||
|
||||
// discount out-of-order packets from loss to deal with a scenario like
|
||||
// 1. up stream has loss
|
||||
// 2. down stream forwards with loss/hole in sequence number
|
||||
// 3. down stream client reports a certain number of loss via RTCP RR
|
||||
// 4. while processing that RTCP RR, up stream could have retransmitted missing packets
|
||||
// 5. those retransmitted packets are forwarded,
|
||||
// - server's view: it has forwarded those packets
|
||||
// - client's view: it had not seen those packets when sending RTCP RR
|
||||
// so those retransmitted packets appear like down stream loss to server.
|
||||
//
|
||||
// retransmitted packets would have arrived out-of-order. So, discounting them
|
||||
// will account for it.
|
||||
//
|
||||
// Note that packets can arrive out-of-order in the upstream during regular
|
||||
// streaming as well, i. e. without loss + NACK + retransmit. Those will be
|
||||
// discounted too. And that will skew the real loss. For example, let
|
||||
// us say that 40 out of 100 packets were reported lost by down stream.
|
||||
// These could be real losses. In the same window, 40 packets could have been
|
||||
// delivered out-of-order by the up stream, thus cancelling out the real loss.
|
||||
// But, those situations should be rare and is a compromise for not letting
|
||||
// up stream loss penalise down stream.
|
||||
actualLost := w.packetsLost - w.packetsMissing - w.packetsOutOfOrder
|
||||
if int32(actualLost) < 0 {
|
||||
actualLost = 0
|
||||
}
|
||||
|
||||
var lossEffect float64
|
||||
if w.packets+w.packetsPadding > 0 {
|
||||
lossEffect = float64(actualLost) * 100.0 / float64(w.packets+w.packetsPadding)
|
||||
}
|
||||
lossEffect *= aplw
|
||||
|
||||
score := cMaxScore - delayEffect - lossEffect
|
||||
if score < 0.0 {
|
||||
score = 0.0
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
func (w *windowStat) calculateBitrateScore(expectedBits int64, isEnabled bool) float64 {
|
||||
if expectedBits == 0 || !isEnabled {
|
||||
// unsupported mode OR all layers stopped
|
||||
return cMaxScore
|
||||
}
|
||||
|
||||
var score float64
|
||||
if w.bytes != 0 {
|
||||
// using the ratio of expectedBits / actualBits
|
||||
// the quality inflection points are approximately
|
||||
// GOOD at ~2.7x, POOR at ~20.1x
|
||||
score = cMaxScore - 20*math.Log(float64(expectedBits)/float64(w.bytes*8))
|
||||
if score > cMaxScore {
|
||||
score = cMaxScore
|
||||
}
|
||||
if score < 0.0 {
|
||||
score = 0.0
|
||||
}
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
func (w *windowStat) String() string {
|
||||
return fmt.Sprintf("start: %+v, dur: %+v, p: %d, pp: %d, pl: %d, pm: %d, pooo: %d, b: %d, rtt: %d, jitter: %0.2f, lastRTCP: %+v",
|
||||
w.startedAt,
|
||||
w.duration,
|
||||
w.packets,
|
||||
w.packetsPadding,
|
||||
w.packetsLost,
|
||||
w.packetsMissing,
|
||||
w.packetsOutOfOrder,
|
||||
w.bytes,
|
||||
w.rttMax,
|
||||
w.jitterMax,
|
||||
w.lastRTCPAt,
|
||||
)
|
||||
}
|
||||
|
||||
func (w *windowStat) MarshalLogObject(e zapcore.ObjectEncoder) error {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
e.AddTime("startedAt", w.startedAt)
|
||||
e.AddString("duration", w.duration.String())
|
||||
e.AddUint32("packets", w.packets)
|
||||
e.AddUint32("packetsPadding", w.packetsPadding)
|
||||
e.AddUint32("packetsLost", w.packetsLost)
|
||||
e.AddUint32("packetsMissing", w.packetsMissing)
|
||||
e.AddUint32("packetsOutOfOrder", w.packetsOutOfOrder)
|
||||
e.AddUint64("bytes", w.bytes)
|
||||
e.AddUint32("rttMax", w.rttMax)
|
||||
e.AddFloat64("jitterMax", w.jitterMax)
|
||||
e.AddTime("lastRTCPAt", w.lastRTCPAt)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ------------------------------------------
|
||||
|
||||
type qualityScorerParams struct {
|
||||
IncludeRTT bool
|
||||
IncludeJitter bool
|
||||
EnableBitrateScore bool
|
||||
Logger logger.Logger
|
||||
}
|
||||
|
||||
type qualityScorer struct {
|
||||
params qualityScorerParams
|
||||
|
||||
lock sync.RWMutex
|
||||
lastUpdateAt time.Time
|
||||
|
||||
packetLossWeight float64
|
||||
|
||||
score float64
|
||||
stat windowStat
|
||||
|
||||
mutedAt time.Time
|
||||
unmutedAt time.Time
|
||||
|
||||
layerMutedAt time.Time
|
||||
layerUnmutedAt time.Time
|
||||
|
||||
pausedAt time.Time
|
||||
resumedAt time.Time
|
||||
|
||||
ppsHistogram [250]int
|
||||
numPPSReadings int
|
||||
ppsMode int
|
||||
modeCalculatedAt time.Time
|
||||
|
||||
aggregateBitrate *utils.TimedAggregator[int64]
|
||||
layerDistance *utils.TimedAggregator[float64]
|
||||
}
|
||||
|
||||
func newQualityScorer(params qualityScorerParams) *qualityScorer {
|
||||
return &qualityScorer{
|
||||
params: params,
|
||||
score: cMaxScore,
|
||||
aggregateBitrate: utils.NewTimedAggregator[int64](utils.TimedAggregatorParams{
|
||||
CapNegativeValues: true,
|
||||
}),
|
||||
layerDistance: utils.NewTimedAggregator[float64](utils.TimedAggregatorParams{
|
||||
CapNegativeValues: true,
|
||||
}),
|
||||
modeCalculatedAt: time.Now().Add(-cModeCalculationInterval),
|
||||
}
|
||||
}
|
||||
|
||||
func (q *qualityScorer) startAtLocked(packetLossWeight float64, at time.Time) {
|
||||
q.packetLossWeight = packetLossWeight
|
||||
q.lastUpdateAt = at
|
||||
}
|
||||
|
||||
func (q *qualityScorer) StartAt(packetLossWeight float64, at time.Time) {
|
||||
q.lock.Lock()
|
||||
defer q.lock.Unlock()
|
||||
|
||||
q.startAtLocked(packetLossWeight, at)
|
||||
}
|
||||
|
||||
func (q *qualityScorer) Start(packetLossWeight float64) {
|
||||
q.lock.Lock()
|
||||
defer q.lock.Unlock()
|
||||
|
||||
q.startAtLocked(packetLossWeight, time.Now())
|
||||
}
|
||||
|
||||
func (q *qualityScorer) UpdatePacketLossWeight(packetLossWeight float64) {
|
||||
q.lock.Lock()
|
||||
defer q.lock.Unlock()
|
||||
|
||||
q.packetLossWeight = packetLossWeight
|
||||
}
|
||||
|
||||
func (q *qualityScorer) updateMuteAtLocked(isMuted bool, at time.Time) {
|
||||
if isMuted {
|
||||
q.mutedAt = at
|
||||
// muting when LOST should not push quality to EXCELLENT
|
||||
if q.score != qualityTransitionScore[livekit.ConnectionQuality_LOST] {
|
||||
q.score = cMaxScore
|
||||
}
|
||||
} else {
|
||||
q.unmutedAt = at
|
||||
}
|
||||
}
|
||||
|
||||
func (q *qualityScorer) UpdateMuteAt(isMuted bool, at time.Time) {
|
||||
q.lock.Lock()
|
||||
defer q.lock.Unlock()
|
||||
|
||||
q.updateMuteAtLocked(isMuted, at)
|
||||
}
|
||||
|
||||
func (q *qualityScorer) UpdateMute(isMuted bool) {
|
||||
q.lock.Lock()
|
||||
defer q.lock.Unlock()
|
||||
|
||||
q.updateMuteAtLocked(isMuted, time.Now())
|
||||
}
|
||||
|
||||
func (q *qualityScorer) addBitrateTransitionAtLocked(bitrate int64, at time.Time) {
|
||||
q.aggregateBitrate.AddSampleAt(bitrate, at)
|
||||
}
|
||||
|
||||
func (q *qualityScorer) AddBitrateTransitionAt(bitrate int64, at time.Time) {
|
||||
q.lock.Lock()
|
||||
defer q.lock.Unlock()
|
||||
|
||||
q.addBitrateTransitionAtLocked(bitrate, at)
|
||||
}
|
||||
|
||||
func (q *qualityScorer) AddBitrateTransition(bitrate int64) {
|
||||
q.lock.Lock()
|
||||
defer q.lock.Unlock()
|
||||
|
||||
q.addBitrateTransitionAtLocked(bitrate, time.Now())
|
||||
}
|
||||
|
||||
func (q *qualityScorer) updateLayerMuteAtLocked(isMuted bool, at time.Time) {
|
||||
if isMuted {
|
||||
if !q.isLayerMuted() {
|
||||
q.aggregateBitrate.Reset()
|
||||
q.layerDistance.Reset()
|
||||
q.layerMutedAt = at
|
||||
q.score = cMaxScore
|
||||
}
|
||||
} else {
|
||||
if q.isLayerMuted() {
|
||||
q.layerUnmutedAt = at
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (q *qualityScorer) UpdateLayerMuteAt(isMuted bool, at time.Time) {
|
||||
q.lock.Lock()
|
||||
defer q.lock.Unlock()
|
||||
|
||||
q.updateLayerMuteAtLocked(isMuted, at)
|
||||
}
|
||||
|
||||
func (q *qualityScorer) UpdateLayerMute(isMuted bool) {
|
||||
q.lock.Lock()
|
||||
defer q.lock.Unlock()
|
||||
|
||||
q.updateLayerMuteAtLocked(isMuted, time.Now())
|
||||
}
|
||||
|
||||
func (q *qualityScorer) updatePauseAtLocked(isPaused bool, at time.Time) {
|
||||
if isPaused {
|
||||
if !q.isPaused() {
|
||||
q.aggregateBitrate.Reset()
|
||||
q.layerDistance.Reset()
|
||||
q.pausedAt = at
|
||||
q.score = cMinScore
|
||||
}
|
||||
} else {
|
||||
if q.isPaused() {
|
||||
q.resumedAt = at
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (q *qualityScorer) UpdatePauseAt(isPaused bool, at time.Time) {
|
||||
q.lock.Lock()
|
||||
defer q.lock.Unlock()
|
||||
|
||||
q.updatePauseAtLocked(isPaused, at)
|
||||
}
|
||||
|
||||
func (q *qualityScorer) UpdatePause(isPaused bool) {
|
||||
q.lock.Lock()
|
||||
defer q.lock.Unlock()
|
||||
|
||||
q.updatePauseAtLocked(isPaused, time.Now())
|
||||
}
|
||||
|
||||
func (q *qualityScorer) addLayerTransitionAtLocked(distance float64, at time.Time) {
|
||||
q.layerDistance.AddSampleAt(distance, at)
|
||||
}
|
||||
|
||||
func (q *qualityScorer) AddLayerTransitionAt(distance float64, at time.Time) {
|
||||
q.lock.Lock()
|
||||
defer q.lock.Unlock()
|
||||
|
||||
q.addLayerTransitionAtLocked(distance, at)
|
||||
}
|
||||
|
||||
func (q *qualityScorer) AddLayerTransition(distance float64) {
|
||||
q.lock.Lock()
|
||||
defer q.lock.Unlock()
|
||||
|
||||
q.addLayerTransitionAtLocked(distance, time.Now())
|
||||
}
|
||||
|
||||
func (q *qualityScorer) updateAtLocked(stat *windowStat, at time.Time) {
|
||||
// always update transitions
|
||||
expectedBits, _, err := q.aggregateBitrate.GetAggregateAndRestartAt(at)
|
||||
if err != nil {
|
||||
q.params.Logger.Warnw("error getting expected bitrate", err)
|
||||
}
|
||||
expectedDistance, err := q.layerDistance.GetAverageAndRestartAt(at)
|
||||
if err != nil {
|
||||
q.params.Logger.Warnw("error getting expected distance", err)
|
||||
}
|
||||
|
||||
// nothing to do when muted or not unmuted for long enough
|
||||
// NOTE: it is possible that unmute -> mute -> unmute transition happens in the
|
||||
// same analysis window. On a transition to mute, quality is immediately moved
|
||||
// EXCELLENT for responsiveness. On an unmute, the entire window data is
|
||||
// considered (as long as enough time has passed since unmute).
|
||||
//
|
||||
// Similarly, when paused (possibly due to congestion), score is immediately
|
||||
// set to cMinScore for responsiveness. The layer transition is reset.
|
||||
// On a resume, quality climbs back up using normal operation.
|
||||
if q.isMuted() || !q.isUnmutedEnough(at) || q.isLayerMuted() || q.isPaused() {
|
||||
q.lastUpdateAt = at
|
||||
return
|
||||
}
|
||||
|
||||
aplw := q.getAdjustedPacketLossWeight(stat)
|
||||
reason := "none"
|
||||
var score, packetScore, bitrateScore, layerScore float64
|
||||
if stat.packets+stat.packetsPadding == 0 {
|
||||
if !stat.lastRTCPAt.IsZero() && at.Sub(stat.lastRTCPAt) > stat.duration {
|
||||
reason = "rtcp"
|
||||
score = qualityTransitionScore[livekit.ConnectionQuality_LOST]
|
||||
} else {
|
||||
reason = "dry"
|
||||
score = qualityTransitionScore[livekit.ConnectionQuality_POOR]
|
||||
}
|
||||
} else {
|
||||
packetScore = stat.calculatePacketScore(aplw, q.params.IncludeRTT, q.params.IncludeJitter)
|
||||
bitrateScore = stat.calculateBitrateScore(expectedBits, q.params.EnableBitrateScore)
|
||||
layerScore = math.Max(math.Min(cMaxScore, cMaxScore-(expectedDistance*cDistanceWeight)), 0.0)
|
||||
|
||||
minScore := math.Min(packetScore, bitrateScore)
|
||||
minScore = math.Min(minScore, layerScore)
|
||||
|
||||
switch {
|
||||
case packetScore == minScore:
|
||||
reason = "packet"
|
||||
score = packetScore
|
||||
|
||||
case bitrateScore == minScore:
|
||||
reason = "bitrate"
|
||||
score = bitrateScore
|
||||
|
||||
case layerScore == minScore:
|
||||
reason = "layer"
|
||||
score = layerScore
|
||||
}
|
||||
|
||||
factor := cIncreaseFactor
|
||||
if score < q.score {
|
||||
factor = cDecreaseFactor
|
||||
}
|
||||
score = factor*score + (1.0-factor)*q.score
|
||||
if score < cMinScore {
|
||||
// lower bound to prevent score from becoming very small values due to extreme conditions.
|
||||
// Without a lower bound, it can get so low that it takes a long time to climb back to
|
||||
// better quality even under excellent conditions.
|
||||
score = cMinScore
|
||||
}
|
||||
}
|
||||
|
||||
prevCQ := scoreToConnectionQuality(q.score)
|
||||
currCQ := scoreToConnectionQuality(score)
|
||||
ulgr := q.params.Logger.WithUnlikelyValues(
|
||||
"reason", reason,
|
||||
"prevScore", q.score,
|
||||
"prevQuality", prevCQ,
|
||||
"prevStat", &q.stat,
|
||||
"score", score,
|
||||
"packetScore", packetScore,
|
||||
"layerScore", layerScore,
|
||||
"bitrateScore", bitrateScore,
|
||||
"quality", currCQ,
|
||||
"stat", stat,
|
||||
"packetLossWeight", q.packetLossWeight,
|
||||
"adjustedPacketLossWeight", aplw,
|
||||
"modePPS", q.ppsMode*int(cPPSQuantization),
|
||||
"expectedBits", expectedBits,
|
||||
"expectedDistance", expectedDistance,
|
||||
)
|
||||
switch {
|
||||
case utils.IsConnectionQualityLower(prevCQ, currCQ):
|
||||
ulgr.Debugw("quality drop")
|
||||
case utils.IsConnectionQualityHigher(prevCQ, currCQ):
|
||||
ulgr.Debugw("quality rise")
|
||||
default:
|
||||
packets := stat.packets + stat.packetsPadding
|
||||
if packets != 0 && (stat.packetsLost*100/packets) > 10 {
|
||||
ulgr.Debugw("quality hold - high loss")
|
||||
}
|
||||
}
|
||||
|
||||
q.score = score
|
||||
q.stat = *stat
|
||||
q.lastUpdateAt = at
|
||||
}
|
||||
|
||||
func (q *qualityScorer) UpdateAt(stat *windowStat, at time.Time) {
|
||||
q.lock.Lock()
|
||||
defer q.lock.Unlock()
|
||||
|
||||
q.updateAtLocked(stat, at)
|
||||
}
|
||||
|
||||
func (q *qualityScorer) Update(stat *windowStat) {
|
||||
q.lock.Lock()
|
||||
defer q.lock.Unlock()
|
||||
|
||||
q.updateAtLocked(stat, time.Now())
|
||||
}
|
||||
|
||||
func (q *qualityScorer) isMuted() bool {
|
||||
return !q.mutedAt.IsZero() && (q.unmutedAt.IsZero() || q.mutedAt.After(q.unmutedAt))
|
||||
}
|
||||
|
||||
func (q *qualityScorer) isUnmutedEnough(at time.Time) bool {
|
||||
var sinceUnmute time.Duration
|
||||
if q.unmutedAt.IsZero() {
|
||||
sinceUnmute = at.Sub(q.lastUpdateAt)
|
||||
} else {
|
||||
sinceUnmute = at.Sub(q.unmutedAt)
|
||||
}
|
||||
|
||||
var sinceLayerUnmute time.Duration
|
||||
if q.layerUnmutedAt.IsZero() {
|
||||
sinceLayerUnmute = at.Sub(q.lastUpdateAt)
|
||||
} else {
|
||||
sinceLayerUnmute = at.Sub(q.layerUnmutedAt)
|
||||
}
|
||||
|
||||
validDuration := sinceUnmute
|
||||
if sinceLayerUnmute < validDuration {
|
||||
validDuration = sinceLayerUnmute
|
||||
}
|
||||
|
||||
sinceLastUpdate := at.Sub(q.lastUpdateAt)
|
||||
|
||||
return validDuration.Seconds()/sinceLastUpdate.Seconds() > cUnmuteTimeThreshold
|
||||
}
|
||||
|
||||
func (q *qualityScorer) isLayerMuted() bool {
|
||||
return !q.layerMutedAt.IsZero() && (q.layerUnmutedAt.IsZero() || q.layerMutedAt.After(q.layerUnmutedAt))
|
||||
}
|
||||
|
||||
func (q *qualityScorer) isPaused() bool {
|
||||
return !q.pausedAt.IsZero() && (q.resumedAt.IsZero() || q.pausedAt.After(q.resumedAt))
|
||||
}
|
||||
|
||||
func (q *qualityScorer) getAdjustedPacketLossWeight(stat *windowStat) float64 {
|
||||
if stat == nil || stat.duration <= 0 {
|
||||
return q.packetLossWeight
|
||||
}
|
||||
|
||||
// packet loss is weighted by comparing against mode of packet rate seen.
|
||||
// this is to handle situations like DTX in audio and variable bit rate tracks like screen share.
|
||||
// and the effect of loss is not pronounced in those scenarios (audio silence, static screen share).
|
||||
// for example, DTX typically uses only 5% of packets of full packet rate. at that rate,
|
||||
// packet loss weight is reduced to ~22% of configured weight (i. e. sqrt(0.05) * configured weight)
|
||||
pps := float64(stat.packets) / stat.duration.Seconds()
|
||||
ppsQuantized := int(pps/cPPSQuantization + 0.5)
|
||||
if ppsQuantized < len(q.ppsHistogram)-1 {
|
||||
q.ppsHistogram[ppsQuantized]++
|
||||
} else {
|
||||
q.ppsHistogram[len(q.ppsHistogram)-1]++
|
||||
}
|
||||
q.numPPSReadings++
|
||||
|
||||
// calculate mode sparingly, do it under the following conditions
|
||||
// 1. minimum number of readings available (AND)
|
||||
// 2. enough time has elapsed since last calculation
|
||||
if q.numPPSReadings > cPPSMinReadings && time.Since(q.modeCalculatedAt) > cModeCalculationInterval {
|
||||
q.ppsMode = 0
|
||||
for i := 0; i < len(q.ppsHistogram); i++ {
|
||||
if q.ppsHistogram[i] > q.ppsMode {
|
||||
q.ppsMode = i
|
||||
}
|
||||
}
|
||||
q.modeCalculatedAt = time.Now()
|
||||
q.params.Logger.Debugw("updating pps mode", "expected", stat.packets, "duration", stat.duration.Seconds(), "pps", pps, "ppsMode", q.ppsMode)
|
||||
}
|
||||
|
||||
if q.ppsMode == 0 || q.ppsMode == len(q.ppsHistogram)-1 {
|
||||
return q.packetLossWeight
|
||||
}
|
||||
|
||||
packetRatio := pps / (float64(q.ppsMode) * cPPSQuantization)
|
||||
if packetRatio > 1.0 {
|
||||
packetRatio = 1.0
|
||||
}
|
||||
return math.Sqrt(packetRatio) * q.packetLossWeight
|
||||
}
|
||||
|
||||
func (q *qualityScorer) GetScoreAndQuality() (float32, livekit.ConnectionQuality) {
|
||||
q.lock.RLock()
|
||||
defer q.lock.RUnlock()
|
||||
|
||||
return float32(q.score), scoreToConnectionQuality(q.score)
|
||||
}
|
||||
|
||||
func (q *qualityScorer) GetMOSAndQuality() (float32, livekit.ConnectionQuality) {
|
||||
q.lock.RLock()
|
||||
defer q.lock.RUnlock()
|
||||
|
||||
return scoreToMOS(q.score), scoreToConnectionQuality(q.score)
|
||||
}
|
||||
|
||||
// ------------------------------------------
|
||||
|
||||
func scoreToConnectionQuality(score float64) livekit.ConnectionQuality {
|
||||
// R-factor -> livekit.ConnectionQuality scale mapping roughly based on
|
||||
// https://www.itu.int/ITU-T/2005-2008/com12/emodelv1/tut.htm
|
||||
//
|
||||
// As there are only three levels in livekit.ConnectionQuality scale,
|
||||
// using a larger range for middling quality. Empirical evidence suggests
|
||||
// that a score of 60 does not correspond to `POOR` quality. Repair
|
||||
// mechanisms and use of algorithms like de-jittering makes the experience
|
||||
// better even under harsh conditions.
|
||||
if score > qualityTransitionScore[livekit.ConnectionQuality_GOOD] {
|
||||
return livekit.ConnectionQuality_EXCELLENT
|
||||
}
|
||||
|
||||
if score > qualityTransitionScore[livekit.ConnectionQuality_POOR] {
|
||||
return livekit.ConnectionQuality_GOOD
|
||||
}
|
||||
|
||||
if score > qualityTransitionScore[livekit.ConnectionQuality_LOST] {
|
||||
return livekit.ConnectionQuality_POOR
|
||||
}
|
||||
|
||||
return livekit.ConnectionQuality_LOST
|
||||
}
|
||||
|
||||
// ------------------------------------------
|
||||
|
||||
func scoreToMOS(score float64) float32 {
|
||||
if score <= 0.0 {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
if score >= 100.0 {
|
||||
return 4.5
|
||||
}
|
||||
|
||||
return float32(1.0 + 0.035*score + (0.000007 * score * (score - 60.0) * (100.0 - score)))
|
||||
}
|
||||
|
||||
// ------------------------------------------
|
||||
Reference in New Issue
Block a user