import browser from '../browser';
import { browsers } from 'js-utils';
import * as StatisticsEvents from '../../service/statistics/Events';
import * as MediaType from '../../service/RTC/MediaType';
const GlobalOnErrorHandler = require('../util/GlobalOnErrorHandler');
const logger = require('jitsi-meet-logger').getLogger(__filename);
/* Whether we support the browser we are running into for logging statistics */
const browserSupported = browser.isChrome()
|| browser.isOpera() || browser.isFirefox()
|| browser.isNWJS() || browser.isElectron()
|| browser.isEdge()
|| browser.isSafariWithWebrtc() || browser.isReactNative();
/**
* The lib-jitsi-meet browser-agnostic names of the browser-specific keys
* reported by RTCPeerConnection#getStats mapped by browser.
*/
const KEYS_BY_BROWSER_TYPE = {};
KEYS_BY_BROWSER_TYPE[browsers.FIREFOX] = {
'ssrc': 'ssrc',
'packetsReceived': 'packetsReceived',
'packetsLost': 'packetsLost',
'packetsSent': 'packetsSent',
'bytesReceived': 'bytesReceived',
'bytesSent': 'bytesSent',
'framerateMean': 'framerateMean',
'ip': 'ipAddress',
'port': 'portNumber',
'protocol': 'transport'
};
KEYS_BY_BROWSER_TYPE[browsers.CHROME] = {
'receiveBandwidth': 'googAvailableReceiveBandwidth',
'sendBandwidth': 'googAvailableSendBandwidth',
'remoteAddress': 'googRemoteAddress',
'transportType': 'googTransportType',
'localAddress': 'googLocalAddress',
'activeConnection': 'googActiveConnection',
'ssrc': 'ssrc',
'packetsReceived': 'packetsReceived',
'packetsSent': 'packetsSent',
'packetsLost': 'packetsLost',
'bytesReceived': 'bytesReceived',
'bytesSent': 'bytesSent',
'googFrameHeightReceived': 'googFrameHeightReceived',
'googFrameWidthReceived': 'googFrameWidthReceived',
'googFrameHeightSent': 'googFrameHeightSent',
'googFrameWidthSent': 'googFrameWidthSent',
'googFrameRateReceived': 'googFrameRateReceived',
'googFrameRateSent': 'googFrameRateSent',
'audioInputLevel': 'audioInputLevel',
'audioOutputLevel': 'audioOutputLevel',
'currentRoundTripTime': 'googRtt',
'remoteCandidateType': 'googRemoteCandidateType',
'localCandidateType': 'googLocalCandidateType',
'ip': 'ip',
'port': 'port',
'protocol': 'protocol'
};
KEYS_BY_BROWSER_TYPE[browsers.EDGE] = {
'sendBandwidth': 'googAvailableSendBandwidth',
'remoteAddress': 'remoteAddress',
'transportType': 'protocol',
'localAddress': 'localAddress',
'activeConnection': 'activeConnection',
'ssrc': 'ssrc',
'packetsReceived': 'packetsReceived',
'packetsSent': 'packetsSent',
'packetsLost': 'packetsLost',
'bytesReceived': 'bytesReceived',
'bytesSent': 'bytesSent',
'googFrameHeightReceived': 'frameHeight',
'googFrameWidthReceived': 'frameWidth',
'googFrameHeightSent': 'frameHeight',
'googFrameWidthSent': 'frameWidth',
'googFrameRateReceived': 'framesPerSecond',
'googFrameRateSent': 'framesPerSecond',
'audioInputLevel': 'audioLevel',
'audioOutputLevel': 'audioLevel',
'currentRoundTripTime': 'roundTripTime'
};
KEYS_BY_BROWSER_TYPE[browsers.OPERA]
= KEYS_BY_BROWSER_TYPE[browsers.CHROME];
KEYS_BY_BROWSER_TYPE[browsers.NWJS]
= KEYS_BY_BROWSER_TYPE[browsers.CHROME];
KEYS_BY_BROWSER_TYPE[browsers.ELECTRON]
= KEYS_BY_BROWSER_TYPE[browsers.CHROME];
KEYS_BY_BROWSER_TYPE[browsers.SAFARI]
= KEYS_BY_BROWSER_TYPE[browsers.CHROME];
KEYS_BY_BROWSER_TYPE[browsers.REACT_NATIVE]
= KEYS_BY_BROWSER_TYPE[browsers.CHROME];
/**
* Calculates packet lost percent using the number of lost packets and the
* number of all packet.
* @param lostPackets the number of lost packets
* @param totalPackets the number of all packets.
* @returns {number} packet loss percent
*/
function calculatePacketLoss(lostPackets, totalPackets) {
if (!totalPackets || totalPackets <= 0
|| !lostPackets || lostPackets <= 0) {
return 0;
}
return Math.round((lostPackets / totalPackets) * 100);
}
/**
* Holds "statistics" for a single SSRC.
* @constructor
*/
function SsrcStats() {
this.loss = {};
this.bitrate = {
download: 0,
upload: 0
};
this.resolution = {};
this.framerate = 0;
}
/**
* Sets the "loss" object.
* @param loss the value to set.
*/
SsrcStats.prototype.setLoss = function(loss) {
this.loss = loss || {};
};
/**
* Sets resolution that belong to the ssrc represented by this instance.
* @param resolution new resolution value to be set.
*/
SsrcStats.prototype.setResolution = function(resolution) {
this.resolution = resolution || {};
};
/**
* Adds the "download" and "upload" fields from the "bitrate" parameter to
* the respective fields of the "bitrate" field of this object.
* @param bitrate an object holding the values to add.
*/
SsrcStats.prototype.addBitrate = function(bitrate) {
this.bitrate.download += bitrate.download;
this.bitrate.upload += bitrate.upload;
};
/**
* Resets the bit rate for given ssrc that belong to the peer
* represented by this instance.
*/
SsrcStats.prototype.resetBitrate = function() {
this.bitrate.download = 0;
this.bitrate.upload = 0;
};
/**
* Sets the "framerate".
* @param framerate the value to set.
*/
SsrcStats.prototype.setFramerate = function(framerate) {
this.framerate = framerate || 0;
};
/**
*
*/
function ConferenceStats() {
/**
* The bandwidth
* @type {{}}
*/
this.bandwidth = {};
/**
* The bit rate
* @type {{}}
*/
this.bitrate = {};
/**
* The packet loss rate
* @type {{}}
*/
this.packetLoss = null;
/**
* Array with the transport information.
* @type {Array}
*/
this.transport = [];
}
/* eslint-disable max-params */
/**
* StatsCollector registers for stats updates of given
* peerconnection in given interval. On each update particular
* stats are extracted and put in {@link SsrcStats} objects. Once the processing
* is done audioLevelsUpdateCallback is called with this
* instance as an event source.
*
* @param peerconnection WebRTC PeerConnection object.
* @param audioLevelsInterval
* @param statsInterval stats refresh interval given in ms.
* @param eventEmitter
* @constructor
*/
export default function StatsCollector(
peerconnection,
audioLevelsInterval,
statsInterval,
eventEmitter) {
// StatsCollector depends entirely on the format of the reports returned by
// RTCPeerConnection#getStats. Given that the value of
// browser#getName() is very unlikely to change at runtime, it
// makes sense to discover whether StatsCollector supports the executing
// browser as soon as possible. Otherwise, (1) getStatValue would have to
// needlessly check a "static" condition multiple times very very often and
// (2) the lack of support for the executing browser would be discovered and
// reported multiple times very very often too late in the execution in some
// totally unrelated callback.
/**
* The browser type supported by this StatsCollector. In other words, the
* type of the browser which initialized this StatsCollector
* instance.
* @private
*/
this._browserType = browser.getName();
const keys = KEYS_BY_BROWSER_TYPE[this._browserType];
if (!keys) {
// eslint-disable-next-line no-throw-literal
throw `The browser type '${this._browserType}' isn't supported!`;
}
/**
* Whether to use the Promise-based getStats API or not.
* @type {boolean}
*/
this._usesPromiseGetStats
= browser.isSafariWithWebrtc() || browser.isFirefox();
/**
* The function which is to be used to retrieve the value associated in a
* report returned by RTCPeerConnection#getStats with a lib-jitsi-meet
* browser-agnostic name/key.
*
* @function
* @private
*/
this._getStatValue
= this._usesPromiseGetStats
? this._defineNewGetStatValueMethod(keys)
: this._defineGetStatValueMethod(keys);
this.peerconnection = peerconnection;
this.baselineAudioLevelsReport = null;
this.currentAudioLevelsReport = null;
this.currentStatsReport = null;
this.previousStatsReport = null;
this.audioLevelsIntervalId = null;
this.eventEmitter = eventEmitter;
this.conferenceStats = new ConferenceStats();
// Updates stats interval
this.audioLevelsIntervalMilis = audioLevelsInterval;
this.statsIntervalId = null;
this.statsIntervalMilis = statsInterval;
/**
* Maps SSRC numbers to {@link SsrcStats}.
* @type {MapgetStats method.
* @param error an error that occurred on getStats call.
*/
StatsCollector.prototype.errorCallback = function(error) {
GlobalOnErrorHandler.callErrorHandler(error);
logger.error('Get stats error', error);
this.stop();
};
/**
* Starts stats updates.
*/
StatsCollector.prototype.start = function(startAudioLevelStats) {
const self = this;
if (startAudioLevelStats) {
this.audioLevelsIntervalId = setInterval(
() => {
// Interval updates
self.peerconnection.getStats(
report => {
let results = null;
if (!report || !report.result
|| typeof report.result !== 'function') {
results = report;
} else {
results = report.result();
}
self.currentAudioLevelsReport = results;
if (this._usesPromiseGetStats) {
self.processNewAudioLevelReport();
} else {
self.processAudioLevelReport();
}
self.baselineAudioLevelsReport
= self.currentAudioLevelsReport;
},
self.errorCallback
);
},
self.audioLevelsIntervalMilis
);
}
if (browserSupported) {
this.statsIntervalId = setInterval(
() => {
// Interval updates
self.peerconnection.getStats(
report => {
let results = null;
if (!report || !report.result
|| typeof report.result !== 'function') {
// firefox
results = report;
} else {
// chrome
results = report.result();
}
self.currentStatsReport = results;
try {
if (this._usesPromiseGetStats) {
self.processNewStatsReport();
} else {
self.processStatsReport();
}
} catch (e) {
GlobalOnErrorHandler.callErrorHandler(e);
logger.error(`Unsupported key:${e}`, e);
}
self.previousStatsReport = self.currentStatsReport;
},
self.errorCallback
);
},
self.statsIntervalMilis
);
}
};
/**
* Defines a function which (1) is to be used as a StatsCollector method and (2)
* gets the value from a specific report returned by RTCPeerConnection#getStats
* associated with a lib-jitsi-meet browser-agnostic name.
*
* @param {Object.} keys the map of LibJitsi browser-agnostic
* names to RTCPeerConnection#getStats browser-specific keys
*/
StatsCollector.prototype._defineGetStatValueMethod = function(keys) {
// Define the function which converts a lib-jitsi-meet browser-asnostic name
// to a browser-specific key of a report returned by
// RTCPeerConnection#getStats.
const keyFromName = function(name) {
const key = keys[name];
if (key) {
return key;
}
// eslint-disable-next-line no-throw-literal
throw `The property '${name}' isn't supported!`;
};
// Define the function which retrieves the value from a specific report
// returned by RTCPeerConnection#getStats associated with a given
// browser-specific key.
let itemStatByKey;
switch (this._browserType) {
case browsers.CHROME:
case browsers.OPERA:
case browsers.NWJS:
case browsers.ELECTRON:
// TODO What about other types of browser which are based on Chrome such
// as NW.js? Every time we want to support a new type browser we have to
// go and add more conditions (here and in multiple other places).
// Cannot we do a feature detection instead of a browser type check? For
// example, if item has a stat property of type function, then it's very
// likely that whoever defined it wanted you to call it in order to
// retrieve the value associated with a specific key.
itemStatByKey = (item, key) => item.stat(key);
break;
case browsers.REACT_NATIVE:
// The implementation provided by react-native-webrtc follows the
// Objective-C WebRTC API: RTCStatsReport has a values property of type
// Array in which each element is a key-value pair.
itemStatByKey = function(item, key) {
let value;
item.values.some(pair => {
if (pair.hasOwnProperty(key)) {
value = pair[key];
return true;
}
return false;
});
return value;
};
break;
case browsers.EDGE:
itemStatByKey = (item, key) => item[key];
break;
default:
itemStatByKey = (item, key) => item[key];
}
// Compose the 2 functions defined above to get a function which retrieves
// the value from a specific report returned by RTCPeerConnection#getStats
// associated with a specific lib-jitsi-meet browser-agnostic name.
return (item, name) => itemStatByKey(item, keyFromName(name));
};
/**
* Obtains a stat value from given stat and converts it to a non-negative
* number. If the value is either invalid or negative then 0 will be returned.
* @param report
* @param {string} name
* @return {number}
* @private
*/
StatsCollector.prototype.getNonNegativeStat = function(report, name) {
let value = this._getStatValue(report, name);
if (typeof value !== 'number') {
value = Number(value);
}
if (isNaN(value)) {
return 0;
}
return Math.max(0, value);
};
/* eslint-disable no-continue */
/**
* Stats processing logic.
*/
StatsCollector.prototype.processStatsReport = function() {
if (!this.previousStatsReport) {
return;
}
const getStatValue = this._getStatValue;
const byteSentStats = {};
for (const idx in this.currentStatsReport) {
if (!this.currentStatsReport.hasOwnProperty(idx)) {
continue;
}
const now = this.currentStatsReport[idx];
// The browser API may return "undefined" values in the array
if (!now) {
continue;
}
try {
const receiveBandwidth = getStatValue(now, 'receiveBandwidth');
const sendBandwidth = getStatValue(now, 'sendBandwidth');
if (receiveBandwidth || sendBandwidth) {
this.conferenceStats.bandwidth = {
'download': Math.round(receiveBandwidth / 1000),
'upload': Math.round(sendBandwidth / 1000)
};
}
} catch (e) { /* not supported*/ }
if (now.type === 'googCandidatePair') {
let active, ip, localCandidateType, localip,
remoteCandidateType, rtt, type;
try {
active = getStatValue(now, 'activeConnection');
if (!active) {
continue;
}
ip = getStatValue(now, 'remoteAddress');
type = getStatValue(now, 'transportType');
localip = getStatValue(now, 'localAddress');
localCandidateType = getStatValue(now, 'localCandidateType');
remoteCandidateType = getStatValue(now, 'remoteCandidateType');
rtt = this.getNonNegativeStat(now, 'currentRoundTripTime');
} catch (e) { /* not supported*/ }
if (!ip || !type || !localip || active !== 'true') {
continue;
}
// Save the address unless it has been saved already.
const conferenceStatsTransport = this.conferenceStats.transport;
if (!conferenceStatsTransport.some(
t =>
t.ip === ip
&& t.type === type
&& t.localip === localip)) {
conferenceStatsTransport.push({
ip,
type,
localip,
p2p: this.peerconnection.isP2P,
localCandidateType,
remoteCandidateType,
rtt
});
}
continue;
}
if (now.type === 'candidatepair') {
// we need succeeded and selected pairs only
if (now.state !== 'succeeded' || !now.selected) {
continue;
}
const local = this.currentStatsReport[now.localCandidateId];
const remote = this.currentStatsReport[now.remoteCandidateId];
this.conferenceStats.transport.push({
ip: `${remote.ipAddress}:${remote.portNumber}`,
type: local.transport,
localip: `${local.ipAddress}:${local.portNumber}`,
p2p: this.peerconnection.isP2P,
localCandidateType: local.candidateType,
remoteCandidateType: remote.candidateType
});
}
// NOTE: Edge's proprietary stats via RTCIceTransport.msGetStats().
if (now.msType === 'transportdiagnostics') {
this.conferenceStats.transport.push({
ip: now.remoteAddress,
type: now.protocol,
localip: now.localAddress,
p2p: this.peerconnection.isP2P
});
}
if (now.type !== 'ssrc' && now.type !== 'outboundrtp'
&& now.type !== 'inboundrtp' && now.type !== 'track') {
continue;
}
// NOTE: In Edge, stats with type "inboundrtp" and "outboundrtp" are
// completely useless, so ignore them.
if (browser.isEdge()
&& (now.type === 'inboundrtp' || now.type === 'outboundrtp')) {
continue;
}
const before = this.previousStatsReport[idx];
let ssrc = this.getNonNegativeStat(now, 'ssrc');
// If type="track", take the first SSRC from ssrcIds.
if (now.type === 'track' && Array.isArray(now.ssrcIds)) {
ssrc = Number(now.ssrcIds[0]);
}
if (!before || !ssrc) {
continue;
}
// isRemote is available only in FF and is ignored in case of chrome
// according to the spec
// https://www.w3.org/TR/webrtc-stats/#dom-rtcrtpstreamstats-isremote
// when isRemote is true indicates that the measurements were done at
// the remote endpoint and reported in an RTCP RR/XR.
// Fixes a problem where we are calculating local stats wrong adding
// the sent bytes to the local download bitrate.
// In new W3 stats spec, type="track" has a remoteSource boolean
// property.
// Edge uses the new format, so skip this check.
if (!browser.isEdge()
&& (now.isRemote === true || now.remoteSource === true)) {
continue;
}
let ssrcStats = this.ssrc2stats.get(ssrc);
if (!ssrcStats) {
ssrcStats = new SsrcStats();
this.ssrc2stats.set(ssrc, ssrcStats);
}
let isDownloadStream = true;
let key = 'packetsReceived';
let packetsNow = getStatValue(now, key);
if (typeof packetsNow === 'undefined'
|| packetsNow === null || packetsNow === '') {
isDownloadStream = false;
key = 'packetsSent';
packetsNow = getStatValue(now, key);
if (typeof packetsNow === 'undefined' || packetsNow === null) {
logger.warn('No packetsReceived nor packetsSent stat found');
}
}
if (!packetsNow || packetsNow < 0) {
packetsNow = 0;
}
const packetsBefore = this.getNonNegativeStat(before, key);
const packetsDiff = Math.max(0, packetsNow - packetsBefore);
const packetsLostNow
= this.getNonNegativeStat(now, 'packetsLost');
const packetsLostBefore
= this.getNonNegativeStat(before, 'packetsLost');
const packetsLostDiff = Math.max(0, packetsLostNow - packetsLostBefore);
ssrcStats.setLoss({
packetsTotal: packetsDiff + packetsLostDiff,
packetsLost: packetsLostDiff,
isDownloadStream
});
const bytesReceivedNow
= this.getNonNegativeStat(now, 'bytesReceived');
const bytesReceivedBefore
= this.getNonNegativeStat(before, 'bytesReceived');
const bytesReceived
= Math.max(0, bytesReceivedNow - bytesReceivedBefore);
let bytesSent = 0;
// TODO: clean this mess up!
let nowBytesTransmitted = getStatValue(now, 'bytesSent');
if (typeof nowBytesTransmitted === 'number'
|| typeof nowBytesTransmitted === 'string') {
nowBytesTransmitted = Number(nowBytesTransmitted);
if (!isNaN(nowBytesTransmitted)) {
byteSentStats[ssrc] = nowBytesTransmitted;
if (nowBytesTransmitted > 0) {
bytesSent = nowBytesTransmitted
- getStatValue(before, 'bytesSent');
}
}
}
bytesSent = Math.max(0, bytesSent);
const timeMs = now.timestamp - before.timestamp;
let bitrateReceivedKbps = 0, bitrateSentKbps = 0;
if (timeMs > 0) {
// TODO is there any reason to round here?
bitrateReceivedKbps = Math.round((bytesReceived * 8) / timeMs);
bitrateSentKbps = Math.round((bytesSent * 8) / timeMs);
}
ssrcStats.addBitrate({
'download': bitrateReceivedKbps,
'upload': bitrateSentKbps
});
const resolution = {
height: null,
width: null
};
try {
let height, width;
if ((height = getStatValue(now, 'googFrameHeightReceived'))
&& (width = getStatValue(now, 'googFrameWidthReceived'))) {
resolution.height = height;
resolution.width = width;
} else if ((height = getStatValue(now, 'googFrameHeightSent'))
&& (width = getStatValue(now, 'googFrameWidthSent'))) {
resolution.height = height;
resolution.width = width;
}
} catch (e) { /* not supported*/ }
// Tries to get frame rate
let frameRate;
try {
frameRate = getStatValue(now, 'googFrameRateReceived')
|| getStatValue(now, 'googFrameRateSent') || 0;
} catch (e) {
// if it fails with previous properties(chrome),
// let's try with another one (FF)
try {
frameRate = this.getNonNegativeStat(now, 'framerateMean');
} catch (err) { /* not supported*/ }
}
ssrcStats.setFramerate(Math.round(frameRate || 0));
if (resolution.height && resolution.width) {
ssrcStats.setResolution(resolution);
} else {
ssrcStats.setResolution(null);
}
}
this.eventEmitter.emit(
StatisticsEvents.BYTE_SENT_STATS, this.peerconnection, byteSentStats);
this._processAndEmitReport();
};
/**
*
*/
StatsCollector.prototype._processAndEmitReport = function() {
// process stats
const totalPackets = {
download: 0,
upload: 0
};
const lostPackets = {
download: 0,
upload: 0
};
let bitrateDownload = 0;
let bitrateUpload = 0;
const resolutions = {};
const framerates = {};
let audioBitrateDownload = 0;
let audioBitrateUpload = 0;
let videoBitrateDownload = 0;
let videoBitrateUpload = 0;
for (const [ ssrc, ssrcStats ] of this.ssrc2stats) {
// process packet loss stats
const loss = ssrcStats.loss;
const type = loss.isDownloadStream ? 'download' : 'upload';
totalPackets[type] += loss.packetsTotal;
lostPackets[type] += loss.packetsLost;
// process bitrate stats
bitrateDownload += ssrcStats.bitrate.download;
bitrateUpload += ssrcStats.bitrate.upload;
// collect resolutions and framerates
const track = this.peerconnection.getTrackBySSRC(ssrc);
if (track) {
if (track.isAudioTrack()) {
audioBitrateDownload += ssrcStats.bitrate.download;
audioBitrateUpload += ssrcStats.bitrate.upload;
} else {
videoBitrateDownload += ssrcStats.bitrate.download;
videoBitrateUpload += ssrcStats.bitrate.upload;
}
const participantId = track.getParticipantId();
if (participantId) {
const resolution = ssrcStats.resolution;
if (resolution.width
&& resolution.height
&& resolution.width !== -1
&& resolution.height !== -1) {
const userResolutions = resolutions[participantId] || {};
userResolutions[ssrc] = resolution;
resolutions[participantId] = userResolutions;
}
if (ssrcStats.framerate !== 0) {
const userFramerates = framerates[participantId] || {};
userFramerates[ssrc] = ssrcStats.framerate;
framerates[participantId] = userFramerates;
}
} else {
logger.error(`No participant ID returned by ${track}`);
}
} else if (this.peerconnection.isP2P) {
// NOTE For JVB connection there are JVB tracks reported in
// the stats, but they do not have corresponding JitsiRemoteTrack
// instances stored in TPC. It is not trivial to figure out that
// a SSRC belongs to JVB, so we print this error ony for the P2P
// connection for the time being.
//
// Also there will be reports for tracks removed from the session,
// for the users who have left the conference.
logger.error(
`JitsiTrack not found for SSRC ${ssrc}`
+ ` in ${this.peerconnection}`);
}
ssrcStats.resetBitrate();
}
this.conferenceStats.bitrate = {
'upload': bitrateUpload,
'download': bitrateDownload
};
this.conferenceStats.bitrate.audio = {
'upload': audioBitrateUpload,
'download': audioBitrateDownload
};
this.conferenceStats.bitrate.video = {
'upload': videoBitrateUpload,
'download': videoBitrateDownload
};
this.conferenceStats.packetLoss = {
total:
calculatePacketLoss(
lostPackets.download + lostPackets.upload,
totalPackets.download + totalPackets.upload),
download:
calculatePacketLoss(lostPackets.download, totalPackets.download),
upload:
calculatePacketLoss(lostPackets.upload, totalPackets.upload)
};
this.eventEmitter.emit(
StatisticsEvents.CONNECTION_STATS,
this.peerconnection,
{
'bandwidth': this.conferenceStats.bandwidth,
'bitrate': this.conferenceStats.bitrate,
'packetLoss': this.conferenceStats.packetLoss,
'resolution': resolutions,
'framerate': framerates,
'transport': this.conferenceStats.transport
});
this.conferenceStats.transport = [];
};
/**
* Stats processing logic.
*/
StatsCollector.prototype.processAudioLevelReport = function() {
if (!this.baselineAudioLevelsReport) {
return;
}
const getStatValue = this._getStatValue;
for (const idx in this.currentAudioLevelsReport) {
if (!this.currentAudioLevelsReport.hasOwnProperty(idx)) {
continue;
}
const now = this.currentAudioLevelsReport[idx];
if (now.type !== 'ssrc' && now.type !== 'track') {
continue;
}
const before = this.baselineAudioLevelsReport[idx];
let ssrc = this.getNonNegativeStat(now, 'ssrc');
if (!ssrc && Array.isArray(now.ssrcIds)) {
ssrc = Number(now.ssrcIds[0]);
}
if (!before) {
logger.warn(`${ssrc} not enough data`);
continue;
}
if (!ssrc) {
if ((Date.now() - now.timestamp) < 3000) {
logger.warn('No ssrc: ');
}
continue;
}
// Audio level
let audioLevel;
try {
audioLevel
= getStatValue(now, 'audioInputLevel')
|| getStatValue(now, 'audioOutputLevel');
} catch (e) { /* not supported*/
logger.warn('Audio Levels are not available in the statistics.');
clearInterval(this.audioLevelsIntervalId);
return;
}
if (audioLevel) {
let isLocal;
// If type="ssrc" (legacy) check whether they are received packets.
if (now.type === 'ssrc') {
isLocal = !getStatValue(now, 'packetsReceived');
// If type="track", check remoteSource boolean property.
} else {
isLocal = !now.remoteSource;
}
// According to the W3C WebRTC Stats spec, audioLevel should be in
// 0..1 range (0 == silence). However browsers don't behave that
// way so we must convert it to 0..1.
//
// In Edge the range is -100..0 (-100 == silence) measured in dB,
// so convert to linear. The levels are set to 0 for remote tracks,
// so don't convert those, since 0 means "the maximum" in Edge.
if (browser.isEdge()) {
audioLevel = audioLevel < 0 ? Math.pow(10, audioLevel / 20) : 0;
// TODO: Can't find specs about what this value really is, but it
// seems to vary between 0 and around 32k.
} else {
audioLevel = audioLevel / 32767;
}
this.eventEmitter.emit(
StatisticsEvents.AUDIO_LEVEL,
this.peerconnection,
ssrc,
audioLevel,
isLocal);
}
}
};
/* eslint-enable no-continue */
/**
* New promised based getStats report processing.
* Tested with chrome, firefox and safari. Not switching it on for chrome as
* frameRate stat is missing and calculating it using framesSent,
* gives values double the values seen in webrtc-internals.
* https://w3c.github.io/webrtc-stats/
*/
/**
* Defines a function which (1) is to be used as a StatsCollector method and (2)
* gets the value from a specific report returned by RTCPeerConnection#getStats
* associated with a lib-jitsi-meet browser-agnostic name in case of using
* Promised based getStats.
*
* @param {Object.} keys the map of LibJitsi browser-agnostic
* names to RTCPeerConnection#getStats browser-specific keys
*/
StatsCollector.prototype._defineNewGetStatValueMethod = function(keys) {
// Define the function which converts a lib-jitsi-meet browser-asnostic name
// to a browser-specific key of a report returned by
// RTCPeerConnection#getStats.
const keyFromName = function(name) {
const key = keys[name];
if (key) {
return key;
}
// eslint-disable-next-line no-throw-literal
throw `The property '${name}' isn't supported!`;
};
// Compose the 2 functions defined above to get a function which retrieves
// the value from a specific report returned by RTCPeerConnection#getStats
// associated with a specific lib-jitsi-meet browser-agnostic name.
return (item, name) => item[keyFromName(name)];
};
/**
* Converts the value to a non-negative number.
* If the value is either invalid or negative then 0 will be returned.
* @param {*} v
* @return {number}
* @private
*/
StatsCollector.prototype.getNonNegativeValue = function(v) {
let value = v;
if (typeof value !== 'number') {
value = Number(value);
}
if (isNaN(value)) {
return 0;
}
return Math.max(0, value);
};
/**
* Calculates bitrate between before and now using a supplied field name and its
* value in the stats.
* @param {RTCInboundRtpStreamStats|RTCSentRtpStreamStats} now the current stats
* @param {RTCInboundRtpStreamStats|RTCSentRtpStreamStats} before the
* previous stats.
* @param fieldName the field to use for calculations.
* @return {number} the calculated bitrate between now and before.
* @private
*/
StatsCollector.prototype._calculateBitrate = function(now, before, fieldName) {
const bytesNow = this.getNonNegativeValue(now[fieldName]);
const bytesBefore = this.getNonNegativeValue(before[fieldName]);
const bytesProcessed = Math.max(0, bytesNow - bytesBefore);
const timeMs = now.timestamp - before.timestamp;
let bitrateKbps = 0;
if (timeMs > 0) {
// TODO is there any reason to round here?
bitrateKbps = Math.round((bytesProcessed * 8) / timeMs);
}
return bitrateKbps;
};
/**
* Stats processing new getStats logic.
*/
StatsCollector.prototype.processNewStatsReport = function() {
if (!this.previousStatsReport) {
return;
}
const getStatValue = this._getStatValue;
const byteSentStats = {};
this.currentStatsReport.forEach(now => {
// RTCIceCandidatePairStats
// https://w3c.github.io/webrtc-stats/#candidatepair-dict*
if (now.type === 'candidate-pair'
&& now.nominated
&& now.state === 'succeeded') {
const availableIncomingBitrate = now.availableIncomingBitrate;
const availableOutgoingBitrate = now.availableOutgoingBitrate;
if (availableIncomingBitrate || availableOutgoingBitrate) {
this.conferenceStats.bandwidth = {
'download': Math.round(availableIncomingBitrate / 1000),
'upload': Math.round(availableOutgoingBitrate / 1000)
};
}
const remoteUsedCandidate
= this.currentStatsReport.get(now.remoteCandidateId);
const localUsedCandidate
= this.currentStatsReport.get(now.localCandidateId);
// RTCIceCandidateStats
// https://w3c.github.io/webrtc-stats/#icecandidate-dict*
// safari currently does not provide ice candidates in stats
if (remoteUsedCandidate && localUsedCandidate) {
// FF uses non-standard ipAddress, portNumber, transport
// instead of ip, port, protocol
const remoteIpAddress = getStatValue(remoteUsedCandidate, 'ip');
const remotePort = getStatValue(remoteUsedCandidate, 'port');
const ip = `${remoteIpAddress}:${remotePort}`;
const localIpAddress = getStatValue(localUsedCandidate, 'ip');
const localPort = getStatValue(localUsedCandidate, 'port');
const localIp = `${localIpAddress}:${localPort}`;
const type = getStatValue(remoteUsedCandidate, 'protocol');
// Save the address unless it has been saved already.
const conferenceStatsTransport = this.conferenceStats.transport;
if (!conferenceStatsTransport.some(
t =>
t.ip === ip
&& t.type === type
&& t.localip === localIp)) {
conferenceStatsTransport.push({
ip,
type,
localIp,
p2p: this.peerconnection.isP2P,
localCandidateType: localUsedCandidate.candidateType,
remoteCandidateType: remoteUsedCandidate.candidateType,
networkType: localUsedCandidate.networkType,
rtt: now.currentRoundTripTime * 1000
});
}
}
// RTCReceivedRtpStreamStats
// https://w3c.github.io/webrtc-stats/#receivedrtpstats-dict*
// RTCSentRtpStreamStats
// https://w3c.github.io/webrtc-stats/#sentrtpstats-dict*
} else if (now.type === 'inbound-rtp' || now.type === 'outbound-rtp') {
const before = this.previousStatsReport.get(now.id);
const ssrc = this.getNonNegativeValue(now.ssrc);
if (!before || !ssrc) {
return;
}
let ssrcStats = this.ssrc2stats.get(ssrc);
if (!ssrcStats) {
ssrcStats = new SsrcStats();
this.ssrc2stats.set(ssrc, ssrcStats);
}
let isDownloadStream = true;
let key = 'packetsReceived';
if (now.type === 'outbound-rtp') {
isDownloadStream = false;
key = 'packetsSent';
}
let packetsNow = now[key];
if (!packetsNow || packetsNow < 0) {
packetsNow = 0;
}
const packetsBefore = this.getNonNegativeValue(before[key]);
const packetsDiff = Math.max(0, packetsNow - packetsBefore);
const packetsLostNow
= this.getNonNegativeValue(now.packetsLost);
const packetsLostBefore
= this.getNonNegativeValue(before.packetsLost);
const packetsLostDiff
= Math.max(0, packetsLostNow - packetsLostBefore);
ssrcStats.setLoss({
packetsTotal: packetsDiff + packetsLostDiff,
packetsLost: packetsLostDiff,
isDownloadStream
});
if (now.type === 'inbound-rtp') {
ssrcStats.addBitrate({
'download': this._calculateBitrate(
now, before, 'bytesReceived'),
'upload': 0
});
// RTCInboundRtpStreamStats
// https://w3c.github.io/webrtc-stats/#inboundrtpstats-dict*
// TODO: can we use framesDecoded for frame rate, available
// in chrome
} else {
byteSentStats[ssrc] = this.getNonNegativeValue(now.bytesSent);
ssrcStats.addBitrate({
'download': 0,
'upload': this._calculateBitrate(
now, before, 'bytesSent')
});
// RTCOutboundRtpStreamStats
// https://w3c.github.io/webrtc-stats/#outboundrtpstats-dict*
// TODO: can we use framesEncoded for frame rate, available
// in chrome
}
// FF has framerateMean out of spec
const framerateMean = now.framerateMean;
if (framerateMean) {
ssrcStats.setFramerate(Math.round(framerateMean || 0));
}
// track for resolution
// RTCVideoHandlerStats
// https://w3c.github.io/webrtc-stats/#vststats-dict*
// RTCMediaHandlerStats
// https://w3c.github.io/webrtc-stats/#mststats-dict*
} else if (now.type === 'track') {
const resolution = {
height: now.frameHeight,
width: now.frameWidth
};
// Tries to get frame rate
let frameRate = now.framesPerSecond;
if (!frameRate) {
// we need to calculate it
const before = this.previousStatsReport.get(now.id);
if (before) {
const timeMs = now.timestamp - before.timestamp;
if (timeMs > 0 && now.framesSent) {
const numberOfFramesSinceBefore
= now.framesSent - before.framesSent;
frameRate = (numberOfFramesSinceBefore / timeMs) * 1000;
}
}
if (!frameRate) {
return;
}
}
const trackIdentifier = now.trackIdentifier;
const ssrc = this.peerconnection.getSsrcByTrackId(trackIdentifier);
let ssrcStats = this.ssrc2stats.get(ssrc);
if (!ssrcStats) {
ssrcStats = new SsrcStats();
this.ssrc2stats.set(ssrc, ssrcStats);
}
ssrcStats.setFramerate(Math.round(frameRate || 0));
if (resolution.height && resolution.width) {
ssrcStats.setResolution(resolution);
} else {
ssrcStats.setResolution(null);
}
}
});
this.eventEmitter.emit(
StatisticsEvents.BYTE_SENT_STATS, this.peerconnection, byteSentStats);
this._processAndEmitReport();
};
/**
* Stats processing logic.
*/
StatsCollector.prototype.processNewAudioLevelReport = function() {
if (!this.baselineAudioLevelsReport) {
return;
}
this.currentAudioLevelsReport.forEach(now => {
if (now.type !== 'track') {
return;
}
// Audio level
const audioLevel = now.audioLevel;
if (!audioLevel) {
return;
}
const trackIdentifier = now.trackIdentifier;
const ssrc = this.peerconnection.getSsrcByTrackId(trackIdentifier);
if (ssrc) {
const isLocal
= ssrc === this.peerconnection.getLocalSSRC(
this.peerconnection.getLocalTracks(MediaType.AUDIO));
this.eventEmitter.emit(
StatisticsEvents.AUDIO_LEVEL,
this.peerconnection,
ssrc,
audioLevel,
isLocal);
}
});
};
/**
* End new promised based getStats processing methods.
*/