From 9bfa79ae826a53117cc45a403c45b98325e92537 Mon Sep 17 00:00:00 2001 From: paweldomas Date: Thu, 5 Jun 2014 13:09:31 +0200 Subject: [PATCH] Adds RTP stats processing. --- app.js | 49 +++++ config.js | 3 +- index.html | 1 + libs/colibri/colibri.focus.js | 4 + libs/strophe/strophe.jingle.adapter.js | 6 +- rtp_stats.js | 287 +++++++++++++++++++++++++ 6 files changed, 346 insertions(+), 4 deletions(-) create mode 100644 rtp_stats.js diff --git a/app.js b/app.js index db471bd9b..040934ef8 100644 --- a/app.js +++ b/app.js @@ -8,6 +8,11 @@ var nickname = null; var sharedKey = ''; var roomUrl = null; var ssrc2jid = {}; +/** + * The stats collector that process stats data and triggers updates to app.js. + * @type {StatsCollector} + */ +var statsCollector = null; /** * Indicates whether ssrc is camera video or desktop stream. @@ -464,12 +469,46 @@ function muteVideo(pc, unmute) { ); } +/** + * Callback called by {@link StatsCollector} in intervals supplied to it's + * constructor. + * @param statsCollector {@link StatsCollector} source of the event. + */ +function statsUpdated(statsCollector) +{ + Object.keys(statsCollector.jid2stats).forEach(function (jid) + { + var peerStats = statsCollector.jid2stats[jid]; + Object.keys(peerStats.ssrc2AudioLevel).forEach(function (ssrc) + { + console.info(jid + " audio level: " + + peerStats.ssrc2AudioLevel[ssrc] + " of ssrc: " + ssrc); + }); + }); +} + +/** + * Starts the {@link StatsCollector} if the feature is enabled in config.js. + */ +function startRtpStatsCollector() +{ + if (config.enableRtpStats) + { + statsCollector = new StatsCollector( + getConferenceHandler().peerconnection, 200, statsUpdated); + + statsCollector.start(); + } +} + $(document).bind('callincoming.jingle', function (event, sid) { var sess = connection.jingle.sessions[sid]; // TODO: do we check activecall == null? activecall = sess; + startRtpStatsCollector(); + // TODO: check affiliation and/or role console.log('emuc data for', sess.peerjid, connection.emuc.members[sess.peerjid]); sess.usedrip = true; // not-so-naive trickle ice @@ -478,6 +517,11 @@ $(document).bind('callincoming.jingle', function (event, sid) { }); +$(document).bind('conferenceCreated.jingle', function (event, focus) +{ + startRtpStatsCollector(); +}); + $(document).bind('callactive.jingle', function (event, videoelem, sid) { if (videoelem.attr('id').indexOf('mixedmslabel') === -1) { // ignore mixedmslabela0 and v0 @@ -1165,6 +1209,11 @@ function disposeConference() { } handler.peerconnection.close(); } + if (statsCollector) + { + statsCollector.stop(); + statsCollector = null; + } focus = null; activecall = null; } diff --git a/config.js b/config.js index be326bf83..97ffe74bf 100644 --- a/config.js +++ b/config.js @@ -11,5 +11,6 @@ var config = { bosh: '//lambada.jitsi.net/http-bind', // FIXME: use xep-0156 for that desktopSharing: 'ext', // Desktop sharing method. Can be set to 'ext', 'webrtc' or false to disable. chromeExtensionId: 'diibjkoicjeejcmhdnailmkgecihlobk', // Id of desktop streamer Chrome extension - minChromeExtVersion: '0.1' // Required version of Chrome extension + minChromeExtVersion: '0.1', // Required version of Chrome extension + enableRtpStats: false // Enables RTP stats processing }; \ No newline at end of file diff --git a/index.html b/index.html index d323dd1de..a14a7e2fe 100644 --- a/index.html +++ b/index.html @@ -33,6 +33,7 @@ + diff --git a/libs/colibri/colibri.focus.js b/libs/colibri/colibri.focus.js index 7643f4a24..aa2adaefc 100644 --- a/libs/colibri/colibri.focus.js +++ b/libs/colibri/colibri.focus.js @@ -344,6 +344,10 @@ ColibriFocus.prototype.createdConference = function (result) { for (var i = 0; i < numparticipants; i++) { self.initiate(self.peers[i], true); } + + // Notify we've created the conference + $(document).trigger( + 'conferenceCreated.jingle', self); }, function (error) { console.warn('setLocalDescription failed.', error); diff --git a/libs/strophe/strophe.jingle.adapter.js b/libs/strophe/strophe.jingle.adapter.js index 2aa712fc1..6f9987cd7 100644 --- a/libs/strophe/strophe.jingle.adapter.js +++ b/libs/strophe/strophe.jingle.adapter.js @@ -5,7 +5,7 @@ function TraceablePeerConnection(ice_config, constraints) { this.updateLog = []; this.stats = {}; this.statsinterval = null; - this.maxstats = 300; // limit to 300 values, i.e. 5 minutes; set to 0 to disable + this.maxstats = 0; // limit to 300 values, i.e. 5 minutes; set to 0 to disable /** * Array of ssrcs that will be added on next modifySources call. @@ -88,8 +88,8 @@ function TraceablePeerConnection(ice_config, constraints) { if (self.ondatachannel !== null) { self.ondatachannel(event); } - } - if (!navigator.mozGetUserMedia) { + }; + if (!navigator.mozGetUserMedia && this.maxstats) { this.statsinterval = window.setInterval(function() { self.peerconnection.getStats(function(stats) { var results = stats.result(); diff --git a/rtp_stats.js b/rtp_stats.js new file mode 100644 index 000000000..8bfdcbc38 --- /dev/null +++ b/rtp_stats.js @@ -0,0 +1,287 @@ +/* global ssrc2jid */ + +/** + * Function object which once created can be used to calculate moving average of + * given period. Example for SMA3:
+ * var sma3 = new SimpleMovingAverager(3); + * while(true) // some update loop + * { + * var currentSma3Value = sma3(nextInputValue); + * } + * + * @param period moving average period that will be used by created instance. + * @returns {Function} SMA calculator function of given period. + * @constructor + */ +function SimpleMovingAverager(period) +{ + var nums = []; + return function (num) + { + nums.push(num); + if (nums.length > period) + nums.splice(0, 1); + var sum = 0; + for (var i in nums) + sum += nums[i]; + var n = period; + if (nums.length < period) + n = nums.length; + return (sum / n); + }; +} + +/** + * Peer statistics data holder. + * @constructor + */ +function PeerStats() +{ + this.ssrc2Loss = {}; + this.ssrc2AudioLevel = {}; +} + +/** + * Sets packets loss rate for given ssrc that blong to the peer + * represented by this instance. + * @param ssrc audio or video RTP stream SSRC. + * @param lossRate new packet loss rate value to be set. + */ +PeerStats.prototype.setSsrcLoss = function (ssrc, lossRate) +{ + this.ssrc2Loss[ssrc] = lossRate; +}; + +/** + * Sets new audio level(input or output) for given ssrc that identifies + * the stream which belongs to the peer represented by this instance. + * @param ssrc RTP stream SSRC for which current audio level value will be + * updated. + * @param audioLevel the new audio level value to be set. Value is truncated to + * fit the range from 0 to 1. + */ +PeerStats.prototype.setSsrcAudioLevel = function (ssrc, audioLevel) +{ + // Range limit 0 - 1 + this.ssrc2AudioLevel[ssrc] = Math.min(Math.max(audioLevel, 0), 1); +}; + +/** + * Calculates average packet loss for all streams that belong to the peer + * represented by this instance. + * @returns {number} average packet loss for all streams that belong to the peer + * represented by this instance. + */ +PeerStats.prototype.getAvgLoss = function () +{ + var self = this; + var avg = 0; + var count = Object.keys(this.ssrc2Loss).length; + Object.keys(this.ssrc2Loss).forEach( + function (ssrc) + { + avg += self.ssrc2Loss[ssrc]; + } + ); + return count > 0 ? avg / count : 0; +}; + +/** + * StatsCollector registers for stats updates of given + * peerconnection in given interval. On each update particular + * stats are extracted and put in {@link PeerStats} objects. Once the processing + * is done updateCallback is called with this instance as + * an event source. + * + * @param peerconnection webRTC peer connection object. + * @param interval stats refresh interval given in ms. + * @param {function(StatsCollector)} updateCallback the callback called on stats + * update. + * @constructor + */ +function StatsCollector(peerconnection, interval, updateCallback) +{ + this.peerconnection = peerconnection; + this.baselineReport = null; + this.currentReport = null; + this.intervalId = null; + // Updates stats interval + this.intervalMilis = interval; + // Use SMA 3 to average packet loss changes over time + this.sma3 = new SimpleMovingAverager(3); + // Map of jids to PeerStats + this.jid2stats = {}; + + this.updateCallback = updateCallback; +} + +/** + * Stops stats updates. + */ +StatsCollector.prototype.stop = function () +{ + if (this.intervalId) + { + clearInterval(this.intervalId); + this.intervalId = null; + } +}; + +/** + * Callback passed to getStats method. + * @param error an error that occurred on getStats call. + */ +StatsCollector.prototype.errorCallback = function (error) +{ + console.error("Get stats error", error); + this.stop(); +}; + +/** + * Starts stats updates. + */ +StatsCollector.prototype.start = function () +{ + var self = this; + this.intervalId = setInterval( + function () + { + // Interval updates + self.peerconnection.getStats( + function (report) + { + var results = report.result(); + //console.error("Got interval report", results); + self.currentReport = results; + self.processReport(); + self.baselineReport = self.currentReport; + }, + self.errorCallback + ); + }, + self.intervalMilis + ); +}; + +/** + * Stats processing logic. + */ +StatsCollector.prototype.processReport = function () +{ + if (!this.baselineReport) + { + return; + } + + for (var idx in this.currentReport) + { + var now = this.currentReport[idx]; + if (now.type != 'ssrc') + { + continue; + } + + var before = this.baselineReport[idx]; + if (!before) + { + console.warn(now.stat('ssrc') + ' not enough data'); + continue; + } + + var ssrc = now.stat('ssrc'); + var jid = ssrc2jid[ssrc]; + if (!jid) + { + console.warn("No jid for ssrc: " + ssrc); + continue; + } + + var jidStats = this.jid2stats[jid]; + if (!jidStats) + { + jidStats = new PeerStats(); + this.jid2stats[jid] = jidStats; + } + + // Audio level + var audioLevel = now.stat('audioInputLevel'); + if (!audioLevel) + audioLevel = now.stat('audioOutputLevel'); + if (audioLevel) + { + // TODO: can't find specs about what this value really is, + // but it seems to vary between 0 and around 32k. + audioLevel = audioLevel / 32767; + jidStats.setSsrcAudioLevel(ssrc, audioLevel); + } + + var key = 'packetsReceived'; + if (!now.stat(key)) + { + key = 'packetsSent'; + if (!now.stat(key)) + { + console.error("No packetsReceived nor packetSent stat found"); + this.stop(); + return; + } + } + var packetsNow = now.stat(key); + var packetsBefore = before.stat(key); + var packetRate = packetsNow - packetsBefore; + + var currentLoss = now.stat('packetsLost'); + var previousLoss = before.stat('packetsLost'); + var lossRate = currentLoss - previousLoss; + + var packetsTotal = (packetRate + lossRate); + var lossPercent; + + if (packetsTotal > 0) + lossPercent = lossRate / packetsTotal; + else + lossPercent = 0; + + //console.info(jid + " ssrc: " + ssrc + " " + key + ": " + packetsNow); + + jidStats.setSsrcLoss(ssrc, lossPercent); + } + + var self = this; + // Jid stats + var allPeersAvg = 0; + var jids = Object.keys(this.jid2stats); + jids.forEach( + function (jid) + { + var peerAvg = self.jid2stats[jid].getAvgLoss( + function (avg) + { + //console.info(jid + " stats: " + (avg * 100) + " %"); + allPeersAvg += avg; + } + ); + } + ); + + if (jids.length > 1) + { + // Our streams loss is reported as 0 always, so -1 to length + allPeersAvg = allPeersAvg / (jids.length - 1); + + /** + * Calculates number of connection quality bars from 4(hi) to 0(lo). + */ + var outputAvg = self.sma3(allPeersAvg); + // Linear from 4(0%) to 0(25%). + var quality = Math.round(4 - outputAvg * 16); + quality = Math.max(quality, 0); // lower limit 0 + quality = Math.min(quality, 4); // upper limit 4 + // TODO: quality can be used to indicate connection quality using 4 step + // bar indicator + //console.info("Loss SMA3: " + outputAvg + " Q: " + quality); + } + + self.updateCallback(self); +}; +