twilio-video
Version:
Twilio Video JavaScript Library
1,174 lines (1,041 loc) • 44.3 kB
JavaScript
;
const { flatMap, guessBrowser, guessBrowserVersion } = require('./util');
const { getSdpFormat } = require('./util/sdp');
const guess = guessBrowser();
const guessVersion = guessBrowserVersion();
const isChrome = guess === 'chrome';
const isFirefox = guess === 'firefox';
const isSafari = guess === 'safari';
const chromeMajorVersion = isChrome ? guessVersion.major : null;
const CHROME_LEGACY_MAX_AUDIO_LEVEL = 32767;
/**
* Helper function to find a specific stat from a report.
* Browsers provide the stats report as a Map,
* but Citrix provides stats report as an array.
* @private
*/
function getStatById(report, id) {
if (typeof report.get === 'function') {
return report.get(id);
}
return report.find(s => s.id === id);
}
/**
* Filter the RTCStatsReport to only include stats related to a specific track.
* This function is designed for use with Citrix, where getStats(track) is not supported.
* It includes specific logic to filter the statistics report returned by Citrix and should
* only be used when getStats(track) fails.
*
* @param {RTCStatsReport|Array<RTCStats>} arrayOrMap - Full stats report or array of stats
* @param {MediaStreamTrack} track - The track to filter by
* @param {boolean} [isRemote=false] - Whether this is a remote track
* @returns {RTCStatsReport} Filtered stats
* @private
*/
function filterStatsByTrack(arrayOrMap, track, isRemote = false) {
// Handle different input types
let allStats;
if (Array.isArray(arrayOrMap)) {
allStats = new Map(arrayOrMap.map(stat => [stat.id || String(Math.random()), stat]));
} else if (arrayOrMap instanceof Map) {
allStats = arrayOrMap;
} else if (typeof arrayOrMap === 'object' && arrayOrMap !== null) {
// Handle object-style stats (non-standard)
const statsMap = new Map();
Object.keys(arrayOrMap).forEach(key => {
statsMap.set(key, arrayOrMap[key]);
});
allStats = statsMap;
} else {
return new Map();
}
if (!allStats || !track) {
return new Map();
}
const filteredReport = new Map();
const trackId = track.id;
const trackKind = track.kind;
// Step 1: Find the primary track-specific stats
let primaryStats = null;
let primaryStatsId = null;
let ssrc = null;
// Find the primary stat for this track (inbound-rtp for remote, media-source for local)
for (const [id, stat] of allStats) {
// For remote tracks, find matching inbound-rtp with matching trackIdentifier
if (isRemote && stat.type === 'inbound-rtp' && stat.trackIdentifier === trackId) {
primaryStats = stat;
primaryStatsId = id;
ssrc = stat.ssrc;
break;
} else if (!isRemote && stat.type === 'media-source' && stat.trackIdentifier === trackId) {
// For local tracks, find matching media-source with matching trackIdentifier
primaryStats = stat;
primaryStatsId = id;
break;
} else if (stat.type === 'track' && stat.trackIdentifier === trackId) {
// Also check for track stats with matching trackIdentifier
if (!primaryStats) {
primaryStats = stat;
primaryStatsId = id;
}
}
}
// If no primary stat was found using the trackId, try a more lenient approach
if (!primaryStats) {
// For remote tracks, try to find an inbound-rtp of the correct kind
if (isRemote) {
// Get all inbound-rtp stats of the right kind
const candidateInbounds = [];
for (const [id, stat] of allStats) {
if (stat.type === 'inbound-rtp' && (stat.kind === trackKind || stat.mediaType === trackKind)) {
candidateInbounds.push({ id, stat });
}
}
// If there are multiple candidates, we need to be careful
if (candidateInbounds.length === 1) {
// Only one candidate, use it
primaryStats = candidateInbounds[0].stat;
primaryStatsId = candidateInbounds[0].id;
ssrc = primaryStats.ssrc;
} else if (candidateInbounds.length > 1) {
// Multiple candidates - if we have the trackId, try to match by mid
// otherwise just take the first one
primaryStats = candidateInbounds[0].stat;
primaryStatsId = candidateInbounds[0].id;
ssrc = primaryStats.ssrc;
}
} else {
// For local tracks, try to find a media-source of the correct kind
for (const [id, stat] of allStats) {
if (stat.type === 'media-source' && stat.kind === trackKind) {
primaryStats = stat;
primaryStatsId = id;
break;
}
}
}
}
// If we still didn't find a primary stat, return an empty report
if (!primaryStats) {
return filteredReport;
}
// Step 2: Add the primary stat
filteredReport.set(primaryStatsId, primaryStats);
// Step 3: Add related stats using direct references
const directlyRelatedIds = new Set();
// Track different types of related IDs
if (isRemote) {
// For remote tracks (inbound-rtp is primary)
if (primaryStats.codecId) { directlyRelatedIds.add(primaryStats.codecId); }
if (primaryStats.transportId) { directlyRelatedIds.add(primaryStats.transportId); }
if (primaryStats.remoteId) { directlyRelatedIds.add(primaryStats.remoteId); }
// Find remote-outbound-rtp based on ssrc
if (ssrc) {
for (const [id, stat] of allStats) {
if (stat.type === 'remote-outbound-rtp' && stat.ssrc === ssrc) {
directlyRelatedIds.add(id);
}
}
}
// Add codec, transport, and remote stats
for (const relatedId of directlyRelatedIds) {
if (allStats.has(relatedId)) {
filteredReport.set(relatedId, allStats.get(relatedId));
}
}
// Add the track stats if it exists
for (const [id, stat] of allStats) {
if (stat.type === 'track' && stat.trackIdentifier === trackId) {
filteredReport.set(id, stat);
}
}
} else {
// For local tracks (media-source is primary)
// Find outbound-rtp that references this media source
for (const [id, stat] of allStats) {
if (stat.type === 'outbound-rtp' && stat.mediaSourceId === primaryStatsId) {
filteredReport.set(id, stat);
// Add codec and transport
if (stat.codecId) { directlyRelatedIds.add(stat.codecId); }
if (stat.transportId) { directlyRelatedIds.add(stat.transportId); }
// Find remote-inbound-rtp that references this outbound-rtp
const outboundId = id;
for (const [remoteId, remoteStat] of allStats) {
if (remoteStat.type === 'remote-inbound-rtp' && remoteStat.localId === outboundId) {
filteredReport.set(remoteId, remoteStat);
}
}
}
}
// Add codec and transport stats
for (const relatedId of directlyRelatedIds) {
if (allStats.has(relatedId)) {
filteredReport.set(relatedId, allStats.get(relatedId));
}
}
}
// Step 4: Add candidate pair and certificate info for context
// This is useful information that applies to all tracks
// but doesn't risk mixing data between tracks
let selectedPairId = null;
const transportIds = new Set();
// Find all transport IDs referenced in our filtered stats
for (const stat of filteredReport.values()) {
if (stat.transportId) {
transportIds.add(stat.transportId);
}
}
// Add the transports
for (const transportId of transportIds) {
if (allStats.has(transportId)) {
const transport = allStats.get(transportId);
filteredReport.set(transportId, transport);
// Track the selected candidate pair
if (transport.selectedCandidatePairId) {
selectedPairId = transport.selectedCandidatePairId;
}
// Add certificate info
if (transport.localCertificateId && allStats.has(transport.localCertificateId)) {
filteredReport.set(transport.localCertificateId, allStats.get(transport.localCertificateId));
}
if (transport.remoteCertificateId && allStats.has(transport.remoteCertificateId)) {
filteredReport.set(transport.remoteCertificateId, allStats.get(transport.remoteCertificateId));
}
}
}
// Add only the selected candidate pair, not all candidate pairs
if (selectedPairId && allStats.has(selectedPairId)) {
const selectedPair = allStats.get(selectedPairId);
filteredReport.set(selectedPairId, selectedPair);
// Add the local and remote candidates for the selected pair
if (selectedPair.localCandidateId && allStats.has(selectedPair.localCandidateId)) {
filteredReport.set(selectedPair.localCandidateId, allStats.get(selectedPair.localCandidateId));
}
if (selectedPair.remoteCandidateId && allStats.has(selectedPair.remoteCandidateId)) {
filteredReport.set(selectedPair.remoteCandidateId, allStats.get(selectedPair.remoteCandidateId));
}
}
return filteredReport;
}
/**
* Get the standardized {@link RTCPeerConnection} statistics.
* @param {RTCPeerConnection} peerConnection
* @param {object} [options] - Used for testing
* @returns {Promise.<StandardizedStatsResponse>}
*/
function getStats(peerConnection, options) {
if (!(peerConnection && typeof peerConnection.getStats === 'function')) {
return Promise.reject(new Error('Given PeerConnection does not support getStats'));
}
return _getStats(peerConnection, options);
}
/**
* getStats() implementation.
* @param {RTCPeerConnection} peerConnection
* @param {object} [options] - Used for testing
* @returns {Promise.<StandardizedStatsResponse>}
*/
function _getStats(peerConnection, options) {
const localAudioTracks = getTracks(peerConnection, 'audio', 'local');
const localVideoTracks = getTracks(peerConnection, 'video', 'local');
const remoteAudioTracks = getTracks(peerConnection, 'audio');
const remoteVideoTracks = getTracks(peerConnection, 'video');
const statsResponse = {
activeIceCandidatePair: null,
localAudioTrackStats: [],
localVideoTrackStats: [],
remoteAudioTrackStats: [],
remoteVideoTrackStats: []
};
const trackStatsPromises = flatMap([
[localAudioTracks, 'localAudioTrackStats', false],
[localVideoTracks, 'localVideoTrackStats', false],
[remoteAudioTracks, 'remoteAudioTrackStats', true],
[remoteVideoTracks, 'remoteVideoTrackStats', true]
], ([tracks, statsArrayName, isRemote]) => {
return tracks.map(track => {
return getTrackStats(peerConnection, track, Object.assign({ isRemote }, options)).then(trackStatsArray => {
trackStatsArray.forEach(trackStats => {
trackStats.trackId = track.id;
statsResponse[statsArrayName].push(trackStats);
});
});
});
});
return Promise.all(trackStatsPromises).then(() => {
return getActiveIceCandidatePairStats(peerConnection, options);
}).then(activeIceCandidatePairStatsReport => {
statsResponse.activeIceCandidatePair = activeIceCandidatePairStatsReport;
return statsResponse;
});
}
/**
* Generate the {@link StandardizedActiveIceCandidatePairStatsReport} for the
* {@link RTCPeerConnection}.
* @param {RTCPeerConnection} peerConnection
* @param {object} [options]
* @returns {Promise<StandardizedActiveIceCandidatePairStatsReport>}
*/
function getActiveIceCandidatePairStats(peerConnection, options = {}) {
if (typeof options.testForChrome !== 'undefined' || isChrome
|| typeof options.testForSafari !== 'undefined' || isSafari) {
return peerConnection.getStats().then(
standardizeChromeOrSafariActiveIceCandidatePairStats);
}
if (typeof options.testForFirefox !== 'undefined' || isFirefox) {
return peerConnection.getStats().then(standardizeFirefoxActiveIceCandidatePairStats);
}
return Promise.reject(new Error('RTCPeerConnection#getStats() not supported'));
}
/**
* Standardize the active RTCIceCandidate pair's statistics in Chrome or Safari.
* @param {RTCStatsReport} stats
* @returns {?StandardizedActiveIceCandidatePairStatsReport}
*/
function standardizeChromeOrSafariActiveIceCandidatePairStats(stats) {
const activeCandidatePairStats = Array.from(stats.values()).find(
({ nominated, type }) => type === 'candidate-pair' && nominated
);
if (!activeCandidatePairStats) {
return null;
}
const activeLocalCandidateStats = getStatById(stats, activeCandidatePairStats.localCandidateId);
const activeRemoteCandidateStats = getStatById(stats, activeCandidatePairStats.remoteCandidateId);
const standardizedCandidateStatsKeys = [
{ key: 'candidateType', type: 'string' },
{ key: 'ip', type: 'string' },
{ key: 'port', type: 'number' },
{ key: 'priority', type: 'number' },
{ key: 'protocol', type: 'string' },
{ key: 'url', type: 'string' }
];
const standardizedLocalCandidateStatsKeys = standardizedCandidateStatsKeys.concat([
{ key: 'deleted', type: 'boolean' },
{ key: 'relayProtocol', type: 'string' }
]);
const standatdizedLocalCandidateStatsReport = activeLocalCandidateStats
? standardizedLocalCandidateStatsKeys.reduce((report, { key, type }) => {
report[key] = typeof activeLocalCandidateStats[key] === type
? activeLocalCandidateStats[key]
: key === 'deleted' ? false : null;
return report;
}, {})
: null;
const standardizedRemoteCandidateStatsReport = activeRemoteCandidateStats
? standardizedCandidateStatsKeys.reduce((report, { key, type }) => {
report[key] = typeof activeRemoteCandidateStats[key] === type
? activeRemoteCandidateStats[key]
: null;
return report;
}, {})
: null;
return [
{ key: 'availableIncomingBitrate', type: 'number' },
{ key: 'availableOutgoingBitrate', type: 'number' },
{ key: 'bytesReceived', type: 'number' },
{ key: 'bytesSent', type: 'number' },
{ key: 'consentRequestsSent', type: 'number' },
{ key: 'currentRoundTripTime', type: 'number' },
{ key: 'lastPacketReceivedTimestamp', type: 'number' },
{ key: 'lastPacketSentTimestamp', type: 'number' },
{ key: 'nominated', type: 'boolean' },
{ key: 'priority', type: 'number' },
{ key: 'readable', type: 'boolean' },
{ key: 'requestsReceived', type: 'number' },
{ key: 'requestsSent', type: 'number' },
{ key: 'responsesReceived', type: 'number' },
{ key: 'responsesSent', type: 'number' },
{ key: 'retransmissionsReceived', type: 'number' },
{ key: 'retransmissionsSent', type: 'number' },
{ key: 'state', type: 'string', fixup: state => { return state === 'inprogress' ? 'in-progress' : state; } },
{ key: 'totalRoundTripTime', type: 'number' },
{ key: 'transportId', type: 'string' },
{ key: 'writable', type: 'boolean' }
].reduce((report, { key, type, fixup }) => {
report[key] = typeof activeCandidatePairStats[key] === type
? (fixup ? fixup(activeCandidatePairStats[key]) : activeCandidatePairStats[key])
: null;
return report;
}, {
localCandidate: standatdizedLocalCandidateStatsReport,
remoteCandidate: standardizedRemoteCandidateStatsReport
});
}
/**
* Standardize the active RTCIceCandidate pair's statistics in Firefox.
* @param {RTCStatsReport} stats
* @returns {?StandardizedActiveIceCandidatePairStatsReport}
*/
function standardizeFirefoxActiveIceCandidatePairStats(stats) {
const activeCandidatePairStats = Array.from(stats.values()).find(
({ nominated, type }) => type === 'candidate-pair' && nominated
);
if (!activeCandidatePairStats) {
return null;
}
const activeLocalCandidateStats = getStatById(stats, activeCandidatePairStats.localCandidateId);
const activeRemoteCandidateStats = getStatById(stats, activeCandidatePairStats.remoteCandidateId);
const standardizedCandidateStatsKeys = [
{ key: 'candidateType', type: 'string' },
{ key: 'ip', ffKeys: ['address', 'ipAddress'], type: 'string' },
{ key: 'port', ffKeys: ['portNumber'], type: 'number' },
{ key: 'priority', type: 'number' },
{ key: 'protocol', ffKeys: ['transport'], type: 'string' },
{ key: 'url', type: 'string' }
];
const standardizedLocalCandidateStatsKeys = standardizedCandidateStatsKeys.concat([
{ key: 'deleted', type: 'boolean' },
{ key: 'relayProtocol', type: 'string' }
]);
const candidateTypes = {
host: 'host',
peerreflexive: 'prflx',
relayed: 'relay',
serverreflexive: 'srflx'
};
const standatdizedLocalCandidateStatsReport = activeLocalCandidateStats
? standardizedLocalCandidateStatsKeys.reduce((report, { ffKeys, key, type }) => {
const localStatKey = ffKeys && ffKeys.find(key => key in activeLocalCandidateStats) || key;
report[key] = typeof activeLocalCandidateStats[localStatKey] === type
? localStatKey === 'candidateType'
? candidateTypes[activeLocalCandidateStats[localStatKey]] || activeLocalCandidateStats[localStatKey]
: activeLocalCandidateStats[localStatKey]
: localStatKey === 'deleted' ? false : null;
return report;
}, {})
: null;
const standardizedRemoteCandidateStatsReport = activeRemoteCandidateStats
? standardizedCandidateStatsKeys.reduce((report, { ffKeys, key, type }) => {
const remoteStatKey = ffKeys && ffKeys.find(key => key in activeRemoteCandidateStats) || key;
report[key] = typeof activeRemoteCandidateStats[remoteStatKey] === type
? remoteStatKey === 'candidateType'
? candidateTypes[activeRemoteCandidateStats[remoteStatKey]] || activeRemoteCandidateStats[remoteStatKey]
: activeRemoteCandidateStats[remoteStatKey]
: null;
return report;
}, {})
: null;
return [
{ key: 'availableIncomingBitrate', type: 'number' },
{ key: 'availableOutgoingBitrate', type: 'number' },
{ key: 'bytesReceived', type: 'number' },
{ key: 'bytesSent', type: 'number' },
{ key: 'consentRequestsSent', type: 'number' },
{ key: 'currentRoundTripTime', type: 'number' },
{ key: 'lastPacketReceivedTimestamp', type: 'number' },
{ key: 'lastPacketSentTimestamp', type: 'number' },
{ key: 'nominated', type: 'boolean' },
{ key: 'priority', type: 'number' },
{ key: 'readable', type: 'boolean' },
{ key: 'requestsReceived', type: 'number' },
{ key: 'requestsSent', type: 'number' },
{ key: 'responsesReceived', type: 'number' },
{ key: 'responsesSent', type: 'number' },
{ key: 'retransmissionsReceived', type: 'number' },
{ key: 'retransmissionsSent', type: 'number' },
{ key: 'state', type: 'string' },
{ key: 'totalRoundTripTime', type: 'number' },
{ key: 'transportId', type: 'string' },
{ key: 'writable', type: 'boolean' }
].reduce((report, { key, type }) => {
report[key] = typeof activeCandidatePairStats[key] === type
? activeCandidatePairStats[key]
: null;
return report;
}, {
localCandidate: standatdizedLocalCandidateStatsReport,
remoteCandidate: standardizedRemoteCandidateStatsReport
});
}
/**
* Get local/remote audio/video MediaStreamTracks.
* @param {RTCPeerConnection} peerConnection - The RTCPeerConnection
* @param {string} kind - 'audio' or 'video'
* @param {string} [localOrRemote] - 'local' or 'remote'
* @returns {Array<MediaStreamTrack>}
*/
function getTracks(peerConnection, kind, localOrRemote) {
const getSendersOrReceivers = localOrRemote === 'local' ? 'getSenders' : 'getReceivers';
if (peerConnection[getSendersOrReceivers]) {
return peerConnection[getSendersOrReceivers]()
.map(({ track }) => track)
.filter(track => track && track.kind === kind);
}
const getStreams = localOrRemote === 'local' ? 'getLocalStreams' : 'getRemoteStreams';
const getTracks = kind === 'audio' ? 'getAudioTracks' : 'getVideoTracks';
return flatMap(peerConnection[getStreams](), stream => stream[getTracks]());
}
/**
* Determine if a track is remote by examining the PeerConnection's receivers.
* This function is designed for use with Citrix, where getStats(track) is not supported.
* @param {RTCPeerConnection} peerConnection
* @param {MediaStreamTrack} track
* @returns {boolean} True if the track is a remote track
* @private
*/
function isRemoteTrack(peerConnection, track) {
if (!peerConnection || !track) {
return false;
}
// Check if the track belongs to any receiver (remote)
if (peerConnection.getReceivers) {
const receivers = peerConnection.getReceivers();
for (const receiver of receivers) {
if (receiver.track && receiver.track.id === track.id) {
return true;
}
}
}
// Check remote streams if getReceivers is not available
if (peerConnection.getRemoteStreams) {
const remoteStreams = peerConnection.getRemoteStreams();
for (const stream of remoteStreams) {
const tracks = stream.getTracks();
for (const remoteTrack of tracks) {
if (remoteTrack.id === track.id) {
return true;
}
}
}
}
// The track is not in any remote source, so it's likely local
return false;
}
/**
* Get the standardized statistics for a particular MediaStreamTrack.
* @param {RTCPeerConnection} peerConnection
* @param {MediaStreamTrack} track
* @param {object} [options] - Used for testing
* @returns {Promise.<Array<StandardizedTrackStatsReport>>}
*/
function getTrackStats(peerConnection, track, options = {}) {
if (typeof options.testForChrome !== 'undefined' || isChrome) {
return chromeOrSafariGetTrackStats(peerConnection, track, options);
}
if (typeof options.testForFirefox !== 'undefined' || isFirefox) {
return firefoxGetTrackStats(peerConnection, track, options);
}
if (typeof options.testForSafari !== 'undefined' || isSafari) {
if (typeof options.testForSafari !== 'undefined' || getSdpFormat() === 'unified') {
return chromeOrSafariGetTrackStats(peerConnection, track, options);
}
// NOTE(syerrapragada): getStats() is not supported on
// Safari versions where plan-b is the SDP format
// due to this bug: https://bugs.webkit.org/show_bug.cgi?id=192601
return Promise.reject(new Error([
'getStats() is not supported on this version of Safari',
'due to this bug: https://bugs.webkit.org/show_bug.cgi?id=192601'
].join(' ')));
}
return Promise.reject(new Error('RTCPeerConnection#getStats() not supported'));
}
/**
* Get the standardized statistics for a particular MediaStreamTrack in Chrome or Safari.
* @param {RTCPeerConnection} peerConnection
* @param {MediaStreamTrack} track
* @param {object} options - Used for testing
* @returns {Promise.<Array<StandardizedTrackStatsReport>>}
*/
function chromeOrSafariGetTrackStats(peerConnection, track, options) {
const log = options.log;
if (chromeMajorVersion && chromeMajorVersion < 67) {
return new Promise((resolve, reject) => {
peerConnection.getStats(response => {
resolve([standardizeChromeLegacyStats(response, track)]);
}, null, reject);
});
}
return peerConnection.getStats(track)
.then(response => {
log.info('getStats by track successful');
return standardizeChromeOrSafariStats(response, options);
})
.catch(() => {
// NOTE(lrivas): Citrix doesn't support track-specific getStats,
// so this workaround tries getting all stats and filtering by track.
log.warn('getStats by track failed. Getting default stats');
return peerConnection.getStats()
.then(stats => {
log.info('getStats by default successful');
const isRemote = isRemoteTrack(peerConnection, track);
log.info(`Starting filtering stats for ${isRemote ? 'remote' : 'local'} track`);
const filteredStats = filterStatsByTrack(stats, track, isRemote);
log.info(`Completed filtering stats for ${isRemote ? 'remote' : 'local'} track`);
return standardizeChromeOrSafariStats(filteredStats, options);
});
});
}
/**
* Get the standardized statistics for a particular MediaStreamTrack in Firefox.
* @param {RTCPeerConnection} peerConnection
* @param {MediaStreamTrack} track
* @param {object} options
* @returns {Promise.<Array<StandardizedTrackStatsReport>>}
*/
function firefoxGetTrackStats(peerConnection, track, options) {
return peerConnection.getStats(track).then(response => {
return [standardizeFirefoxStats(response, options)];
});
}
/**
* Standardize the MediaStreamTrack's legacy statistics in Chrome.
* @param {RTCStatsResponse} response
* @param {MediaStreamTrack} track
* @returns {StandardizedTrackStatsReport}
*/
function standardizeChromeLegacyStats(response, track) {
const ssrcReport = response.result().find(report => {
return report.type === 'ssrc' && report.stat('googTrackId') === track.id;
});
let standardizedStats = {};
if (ssrcReport) {
standardizedStats.timestamp = Math.round(Number(ssrcReport.timestamp));
standardizedStats = ssrcReport.names().reduce((stats, name) => {
switch (name) {
case 'googCodecName':
stats.codecName = ssrcReport.stat(name);
break;
case 'googRtt':
stats.roundTripTime = Number(ssrcReport.stat(name));
break;
case 'googJitterReceived':
stats.jitter = Number(ssrcReport.stat(name));
break;
case 'googFrameWidthInput':
stats.frameWidthInput = Number(ssrcReport.stat(name));
break;
case 'googFrameHeightInput':
stats.frameHeightInput = Number(ssrcReport.stat(name));
break;
case 'googFrameWidthSent':
stats.frameWidthSent = Number(ssrcReport.stat(name));
break;
case 'googFrameHeightSent':
stats.frameHeightSent = Number(ssrcReport.stat(name));
break;
case 'googFrameWidthReceived':
stats.frameWidthReceived = Number(ssrcReport.stat(name));
break;
case 'googFrameHeightReceived':
stats.frameHeightReceived = Number(ssrcReport.stat(name));
break;
case 'googFrameRateInput':
stats.frameRateInput = Number(ssrcReport.stat(name));
break;
case 'googFrameRateSent':
stats.frameRateSent = Number(ssrcReport.stat(name));
break;
case 'googFrameRateReceived':
stats.frameRateReceived = Number(ssrcReport.stat(name));
break;
case 'ssrc':
stats[name] = ssrcReport.stat(name);
break;
case 'bytesReceived':
case 'bytesSent':
case 'packetsLost':
case 'packetsReceived':
case 'packetsSent':
case 'audioInputLevel':
case 'audioOutputLevel':
stats[name] = Number(ssrcReport.stat(name));
break;
}
return stats;
}, standardizedStats);
}
return standardizedStats;
}
/**
* Standardize the MediaStreamTrack's statistics in Chrome or Safari.
* @param {RTCStatsReport} response
* @param {object} options - Used for testing
* @returns {Array<StandardizedTrackStatsReport>}
*/
function standardizeChromeOrSafariStats(response, { simulateExceptionWhileStandardizingStats = false }) {
if (simulateExceptionWhileStandardizingStats) {
throw new Error('Error while gathering stats');
}
let inbound = null;
// NOTE(mpatwardhan): We should expect more than one "outbound-rtp" stats for a
// VP8 simulcast MediaStreamTrack.
const outbound = [];
let remoteInbound = null;
let remoteOutbound = null;
let track = null;
let codec = null;
let localMedia = null;
response.forEach(stat => {
const { type } = stat;
switch (type) {
case 'inbound-rtp':
inbound = stat;
break;
case 'outbound-rtp':
outbound.push(stat);
break;
case 'media-source':
localMedia = stat;
break;
case 'track':
track = stat;
break;
case 'codec':
codec = stat;
break;
case 'remote-inbound-rtp':
remoteInbound = stat;
break;
case 'remote-outbound-rtp':
remoteOutbound = stat;
break;
}
});
const isRemote = track ? track.remoteSource : !localMedia;
const mainSources = isRemote ? [inbound] : outbound;
const stats = [];
const remoteSource = isRemote ? remoteOutbound : remoteInbound; // remote rtp stats
mainSources.forEach(source => {
const standardizedStats = {};
const statSources = [
source, // local rtp stats
localMedia,
track,
codec,
remoteSource && remoteSource.ssrc === source.ssrc ? remoteSource : null, // remote rtp stats
];
function getStatValue(name) {
const sourceFound = statSources.find(statSource => {
return statSource && typeof statSource[name] !== 'undefined';
}) || null;
return sourceFound ? sourceFound[name] : null;
}
const ssrc = getStatValue('ssrc');
if (typeof ssrc === 'number') {
standardizedStats.ssrc = String(ssrc);
}
const timestamp = getStatValue('timestamp');
standardizedStats.timestamp = Math.round(timestamp);
let mimeType = getStatValue('mimeType');
if (typeof mimeType === 'string') {
mimeType = mimeType.split('/');
standardizedStats.codecName = mimeType[mimeType.length - 1];
}
const roundTripTime = getStatValue('roundTripTime');
if (typeof roundTripTime === 'number') {
standardizedStats.roundTripTime = Math.round(roundTripTime * 1000);
}
const jitter = getStatValue('jitter');
if (typeof jitter === 'number') {
standardizedStats.jitter = Math.round(jitter * 1000);
}
const frameWidth = getStatValue('frameWidth');
if (typeof frameWidth === 'number') {
if (isRemote) {
standardizedStats.frameWidthReceived = frameWidth;
} else {
standardizedStats.frameWidthSent = frameWidth;
standardizedStats.frameWidthInput = track ? track.frameWidth : localMedia.width;
}
}
const frameHeight = getStatValue('frameHeight');
if (typeof frameHeight === 'number') {
if (isRemote) {
standardizedStats.frameHeightReceived = frameHeight;
} else {
standardizedStats.frameHeightSent = frameHeight;
standardizedStats.frameHeightInput = track ? track.frameHeight : localMedia.height;
}
}
const framesPerSecond = getStatValue('framesPerSecond');
if (typeof framesPerSecond === 'number') {
standardizedStats[isRemote ? 'frameRateReceived' : 'frameRateSent'] = framesPerSecond;
}
const bytesReceived = getStatValue('bytesReceived');
if (typeof bytesReceived === 'number') {
standardizedStats.bytesReceived = bytesReceived;
}
const bytesSent = getStatValue('bytesSent');
if (typeof bytesSent === 'number') {
standardizedStats.bytesSent = bytesSent;
}
const packetsLost = getStatValue('packetsLost');
if (typeof packetsLost === 'number') {
standardizedStats.packetsLost = packetsLost;
}
const packetsReceived = getStatValue('packetsReceived');
if (typeof packetsReceived === 'number') {
standardizedStats.packetsReceived = packetsReceived;
}
const packetsSent = getStatValue('packetsSent');
if (typeof packetsSent === 'number') {
standardizedStats.packetsSent = packetsSent;
}
let audioLevel = getStatValue('audioLevel');
if (typeof audioLevel === 'number') {
audioLevel = Math.round(audioLevel * CHROME_LEGACY_MAX_AUDIO_LEVEL);
if (isRemote) {
standardizedStats.audioOutputLevel = audioLevel;
} else {
standardizedStats.audioInputLevel = audioLevel;
}
}
const totalPacketSendDalay = getStatValue('totalPacketSendDelay');
if (typeof totalPacketSendDalay === 'number') {
standardizedStats.totalPacketSendDelay = totalPacketSendDalay;
}
const totalEncodeTime = getStatValue('totalEncodeTime');
if (typeof totalEncodeTime === 'number') {
standardizedStats.totalEncodeTime = totalEncodeTime;
}
const framesEncoded = getStatValue('framesEncoded');
if (typeof framesEncoded === 'number') {
standardizedStats.framesEncoded = framesEncoded;
}
const estimatedPlayoutTimestamp = getStatValue('estimatedPlayoutTimestamp');
if (typeof estimatedPlayoutTimestamp === 'number') {
standardizedStats.estimatedPlayoutTimestamp = estimatedPlayoutTimestamp;
}
const totalDecodeTime = getStatValue('totalDecodeTime');
if (typeof totalDecodeTime === 'number') {
standardizedStats.totalDecodeTime = totalDecodeTime;
}
const framesDecoded = getStatValue('framesDecoded');
if (typeof framesDecoded === 'number') {
standardizedStats.framesDecoded = framesDecoded;
}
const jitterBufferDelay = getStatValue('jitterBufferDelay');
if (typeof jitterBufferDelay === 'number') {
standardizedStats.jitterBufferDelay = jitterBufferDelay;
}
const jitterBufferEmittedCount = getStatValue('jitterBufferEmittedCount');
if (typeof jitterBufferEmittedCount === 'number') {
standardizedStats.jitterBufferEmittedCount = jitterBufferEmittedCount;
}
stats.push(standardizedStats);
});
return stats;
}
/**
* Standardize the MediaStreamTrack's statistics in Firefox.
* @param {RTCStatsReport} response
* @param {object} options - Used for testing
* @returns {StandardizedTrackStatsReport}
*/
function standardizeFirefoxStats(response = new Map(), { isRemote, simulateExceptionWhileStandardizingStats = false }) {
if (simulateExceptionWhileStandardizingStats) {
throw new Error('Error while gathering stats');
}
// NOTE(mroberts): If getStats is called on a closed RTCPeerConnection,
// Firefox returns undefined instead of an RTCStatsReport. We workaround this
// here. See the following bug for more details:
//
// https://bugzilla.mozilla.org/show_bug.cgi?id=1377225
//
let inbound = null;
let outbound = null;
// NOTE(mmalavalli): Starting from Firefox 63, RTC{Inbound, Outbound}RTPStreamStats.isRemote
// will be deprecated, followed by its removal in Firefox 66. Also, trying to
// access members of the remote RTC{Inbound, Outbound}RTPStreamStats without
// using RTCStatsReport.get(remoteId) will trigger console warnings. So, we
// no longer depend on "isRemote", and we call RTCStatsReport.get(remoteId)
// to access the remote RTC{Inbound, Outbound}RTPStreamStats.
//
// Source: https://blog.mozilla.org/webrtc/getstats-isremote-65/
//
response.forEach(stat => {
const { isRemote, remoteId, type } = stat;
if (isRemote) {
return;
}
switch (type) {
case 'inbound-rtp':
inbound = stat;
outbound = getStatById(response, remoteId);
break;
case 'outbound-rtp':
outbound = stat;
inbound = getStatById(response, remoteId);
break;
}
});
const first = isRemote ? inbound : outbound;
const second = isRemote ? outbound : inbound;
function getStatValue(name) {
if (first && typeof first[name] !== 'undefined') {
return first[name];
}
if (second && typeof second[name] !== 'undefined') {
return second[name];
}
return null;
}
const standardizedStats = {};
const timestamp = getStatValue('timestamp');
standardizedStats.timestamp = Math.round(timestamp);
const ssrc = getStatValue('ssrc');
if (typeof ssrc === 'number') {
standardizedStats.ssrc = String(ssrc);
}
const bytesSent = getStatValue('bytesSent');
if (typeof bytesSent === 'number') {
standardizedStats.bytesSent = bytesSent;
}
const packetsLost = getStatValue('packetsLost');
if (typeof packetsLost === 'number') {
standardizedStats.packetsLost = packetsLost;
}
const packetsSent = getStatValue('packetsSent');
if (typeof packetsSent === 'number') {
standardizedStats.packetsSent = packetsSent;
}
const roundTripTime = getStatValue('roundTripTime');
if (typeof roundTripTime === 'number') {
// roundTripTime is double - measured in seconds.
// https://www.w3.org/TR/webrtc-stats/#dom-rtcremoteinboundrtpstreamstats-roundtriptime
// cover it to milliseconds (and make it integer)
standardizedStats.roundTripTime = Math.round(roundTripTime * 1000);
}
const jitter = getStatValue('jitter');
if (typeof jitter === 'number') {
standardizedStats.jitter = Math.round(jitter * 1000);
}
const frameRateSent = getStatValue('framerateMean');
if (typeof frameRateSent === 'number') {
standardizedStats.frameRateSent = Math.round(frameRateSent);
}
const bytesReceived = getStatValue('bytesReceived');
if (typeof bytesReceived === 'number') {
standardizedStats.bytesReceived = bytesReceived;
}
const packetsReceived = getStatValue('packetsReceived');
if (typeof packetsReceived === 'number') {
standardizedStats.packetsReceived = packetsReceived;
}
const frameRateReceived = getStatValue('framerateMean');
if (typeof frameRateReceived === 'number') {
standardizedStats.frameRateReceived = Math.round(frameRateReceived);
}
const totalPacketSendDalay = getStatValue('totalPacketSendDelay');
if (typeof totalPacketSendDalay === 'number') {
standardizedStats.totalPacketSendDelay = totalPacketSendDalay;
}
const totalEncodeTime = getStatValue('totalEncodeTime');
if (typeof totalEncodeTime === 'number') {
standardizedStats.totalEncodeTime = totalEncodeTime;
}
const framesEncoded = getStatValue('framesEncoded');
if (typeof framesEncoded === 'number') {
standardizedStats.framesEncoded = framesEncoded;
}
const estimatedPlayoutTimestamp = getStatValue('estimatedPlayoutTimestamp');
if (typeof estimatedPlayoutTimestamp === 'number') {
standardizedStats.estimatedPlayoutTimestamp = estimatedPlayoutTimestamp;
}
const totalDecodeTime = getStatValue('totalDecodeTime');
if (typeof totalDecodeTime === 'number') {
standardizedStats.totalDecodeTime = totalDecodeTime;
}
const framesDecoded = getStatValue('framesDecoded');
if (typeof framesDecoded === 'number') {
standardizedStats.framesDecoded = framesDecoded;
}
const jitterBufferDelay = getStatValue('jitterBufferDelay');
if (typeof jitterBufferDelay === 'number') {
standardizedStats.jitterBufferDelay = jitterBufferDelay;
}
const jitterBufferEmittedCount = getStatValue('jitterBufferEmittedCount');
if (typeof jitterBufferEmittedCount === 'number') {
standardizedStats.jitterBufferEmittedCount = jitterBufferEmittedCount;
}
return standardizedStats;
}
/**
* Standardized RTCIceCandidate statistics.
* @typedef {object} StandardizedIceCandidateStatsReport
* @property {'host'|'prflx'|'relay'|'srflx'} candidateType
* @property {string} ip
* @property {number} port
* @property {number} priority
* @property {'tcp'|'udp'} protocol
* @property {string} url
*/
/**
* Standardized local RTCIceCandidate statistics.
* @typedef {StandardizedIceCandidateStatsReport} StandardizedLocalIceCandidateStatsReport
* @property {boolean} [deleted=false]
* @property {'tcp'|'tls'|'udp'} relayProtocol
*/
/**
* Standardized active RTCIceCandidate pair statistics.
* @typedef {object} StandardizedActiveIceCandidatePairStatsReport
* @property {number} availableIncomingBitrate
* @property {number} availableOutgoingBitrate
* @property {number} bytesReceived
* @property {number} bytesSent
* @property {number} consentRequestsSent
* @property {number} currentRoundTripTime
* @property {number} lastPacketReceivedTimestamp
* @property {number} lastPacketSentTimestamp
* @property {StandardizedLocalIceCandidateStatsReport} localCandidate
* @property {boolean} nominated
* @property {number} priority
* @property {boolean} readable
* @property {StandardizedIceCandidateStatsReport} remoteCandidate
* @property {number} requestsReceived
* @property {number} requestsSent
* @property {number} responsesReceived
* @property {number} responsesSent
* @property {number} retransmissionsReceived
* @property {number} retransmissionsSent
* @property {'frozen'|'waiting'|'in-progress'|'failed'|'succeeded'} state
* @property {number} totalRoundTripTime
* @property {string} transportId
* @property {boolean} writable
*/
/**
* Standardized {@link RTCPeerConnection} statistics.
* @typedef {Object} StandardizedStatsResponse
* @property {StandardizedActiveIceCandidatePairStatsReport} activeIceCandidatePair - Stats for active ICE candidate pair
* @property Array<StandardizedTrackStatsReport> localAudioTrackStats - Stats for local audio MediaStreamTracks
* @property Array<StandardizedTrackStatsReport> localVideoTrackStats - Stats for local video MediaStreamTracks
* @property Array<StandardizedTrackStatsReport> remoteAudioTrackStats - Stats for remote audio MediaStreamTracks
* @property Array<StandardizedTrackStatsReport> remoteVideoTrackStats - Stats for remote video MediaStreamTracks
*/
/**
* Standardized MediaStreamTrack statistics.
* @typedef {Object} StandardizedTrackStatsReport
* @property {string} trackId - MediaStreamTrack ID
* @property {string} ssrc - SSRC of the MediaStreamTrack
* @property {number} timestamp - The Unix timestamp in milliseconds
* @property {string} [codecName] - Name of the codec used to encode the MediaStreamTrack's media
* @property {number} [roundTripTime] - Round trip time in milliseconds
* @property {number} [jitter] - Jitter in milliseconds
* @property {number} [frameWidthInput] - Width in pixels of the local video MediaStreamTrack's captured frame
* @property {number} [frameHeightInput] - Height in pixels of the local video MediaStreamTrack's captured frame
* @property {number} [frameWidthSent] - Width in pixels of the local video MediaStreamTrack's encoded frame
* @property {number} [frameHeightSent] - Height in pixels of the local video MediaStreamTrack's encoded frame
* @property {number} [frameWidthReceived] - Width in pixels of the remote video MediaStreamTrack's received frame
* @property {number} [frameHeightReceived] - Height in pixels of the remote video MediaStreamTrack's received frame
* @property {number} [frameRateInput] - Captured frames per second of the local video MediaStreamTrack
* @property {number} [frameRateSent] - Frames per second of the local video MediaStreamTrack's encoded video
* @property {number} [frameRateReceived] - Frames per second of the remote video MediaStreamTrack's received video
* @property {number} [bytesReceived] - Number of bytes of the remote MediaStreamTrack's media received
* @property {number} [bytesSent] - Number of bytes of the local MediaStreamTrack's media sent
* @property {number} [packetsLost] - Number of packets of the MediaStreamTrack's media lost
* @property {number} [packetsReceived] - Number of packets of the remote MediaStreamTrack's media received
* @property {number} [packetsSent] - Number of packets of the local MediaStreamTrack's media sent
* @property {number} [totalPacketSendDelay] - The total number of seconds that the local MediaStreamTrack's packets
* have spent buffered locally before being sent over the network
* @property {number} [totalEncodeTime] - The total number of seconds spent on encoding the local MediaStreamTrack's frames
* @property {number} [framesEncoded] - The total number of frames of the local MediaStreamTrack that have been encoded sor far
* @property {number} [estimatedPlayoutTimestamp] - The estimated playout time of the remote MediaStreamTrack
* @property {number} [totalDecodeTime] - The total number of seconds spent on decoding the remote MediaStreamTrack's frames
* @property {number} [framesDecoded] - The total number of frames of the remote MediaStreamTrack that have been decoded sor far
* @property {number} [jitterBufferDelay] - The sum of the time, in seconds, each audio sample or a video frame of the remote
* MediaStreamTrack takes from the time the first packet is received by the jitter buffer to the time it exits the jitter buffer
* @property {number} [jitterBufferEmittedCount] - The total number of audio samples or video frames that have come out of the jitter buffer
* @property {AudioLevel} [audioInputLevel] - The {@link AudioLevel} of the local audio MediaStreamTrack
* @property {AudioLevel} [audioOutputLevel] - The {@link AudioLevel} of the remote video MediaStreamTrack
*/
module.exports = getStats;