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);
+};
+