signal-desktop/js/calling-tools/stats_rates_calculator.js
2024-05-23 15:19:12 -07:00

609 lines
22 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details
const CalculatorModifier = Object.freeze({
kNone: Object.freeze({postfix: '', multiplier: 1}),
kMillisecondsFromSeconds:
Object.freeze({postfix: '_in_ms', multiplier: 1000}),
kBytesToBits: Object.freeze({bitrate: true, multiplier: 8}),
});
class Metric {
constructor(name, value) {
this.name = name;
this.value = value;
}
toString() {
return '{"' + this.name + '":"' + this.value + '"}';
}
}
// Represents a companion dictionary to an RTCStats object of an RTCStatsReport.
// The CalculatedStats object contains additional metrics associated with the
// original RTCStats object. Typically, the RTCStats object contains
// accumulative counters, but in chrome://webrc-internals/ we also want graphs
// for the average rate over the last second, so we have CalculatedStats
// containing calculated Metrics.
class CalculatedStats {
constructor(id) {
this.id = id;
// A map Original Name -> Array of Metrics, where Original Name refers to
// the name of the metric in the original RTCStats object, and the Metrics
// are calculated metrics. For example, if the original RTCStats report
// contains framesReceived, and from that we've calculated
// [framesReceived/s] and [framesReceived-framesDecoded], then there will be
// a mapping from "framesReceived" to an array of two Metric objects,
// "[framesReceived/s]" and "[framesReceived-framesDecoded]".
this.calculatedMetricsByOriginalName = new Map();
}
addCalculatedMetric(originalName, metric) {
let calculatedMetrics =
this.calculatedMetricsByOriginalName.get(originalName);
if (!calculatedMetrics) {
calculatedMetrics = [];
this.calculatedMetricsByOriginalName.set(originalName, calculatedMetrics);
}
calculatedMetrics.push(metric);
}
// Gets the calculated metrics associated with |originalName| in the order
// that they were added, or an empty list if there are no associated metrics.
getCalculatedMetrics(originalName) {
const calculatedMetrics =
this.calculatedMetricsByOriginalName.get(originalName);
if (!calculatedMetrics) {
return [];
}
return calculatedMetrics;
}
toString() {
let str = '{id:"' + this.id + '"';
for (const originalName of this.calculatedMetricsByOriginalName.keys()) {
const calculatedMetrics =
this.calculatedMetricsByOriginalName.get(originalName);
str += ',' + originalName + ':[';
for (let i = 0; i < calculatedMetrics.length; i++) {
str += calculatedMetrics[i].toString();
if (i + 1 < calculatedMetrics.length) {
str += ',';
}
str += ']';
}
}
str += '}';
return str;
}
}
// Contains the metrics of an RTCStatsReport, as well as calculated metrics
// associated with metrics from the original report. Convertible to and from the
// "internal reports" format used by webrtc_internals.js to pass stats from C++
// to JavaScript.
export class StatsReport {
constructor() {
// Represents an RTCStatsReport. It is a Map RTCStats.id -> RTCStats.
// https://w3c.github.io/webrtc-pc/#dom-rtcstatsreport
this.statsById = new Map();
// RTCStats.id -> CalculatedStats
this.calculatedStatsById = new Map();
}
// |internalReports| is an array, each element represents an RTCStats object,
// but the format is a little different from the spec. This is the format:
// {
// id: "string",
// type: "string",
// stats: {
// timestamp: <milliseconds>,
// values: ["member1", value1, "member2", value2...]
// }
// }
static fromInternalsReportList(internalReports) {
const result = new StatsReport();
internalReports.forEach(internalReport => {
if (!internalReport.stats || !internalReport.stats.values) {
return; // continue;
}
const stats = {
id: internalReport.id,
type: internalReport.type,
timestamp: internalReport.stats.timestamp / 1000.0 // ms -> s
};
const values = internalReport.stats.values;
for (let i = 0; i < values.length; i += 2) {
// Metric "name: value".
stats[values[i]] = values[i + 1];
}
result.statsById.set(stats.id, stats);
});
return result;
}
toInternalsReportList() {
const result = [];
for (const stats of this.statsById.values()) {
const internalReport = {
id: stats.id,
type: stats.type,
stats: {
timestamp: stats.timestamp * 1000.0, // s -> ms
values: []
}
};
Object.keys(stats).forEach(metricName => {
if (metricName === 'id' || metricName === 'type' ||
metricName === 'timestamp') {
return; // continue;
}
internalReport.stats.values.push(metricName);
internalReport.stats.values.push(stats[metricName]);
const calculatedMetrics =
this.getCalculatedMetrics(stats.id, metricName);
calculatedMetrics.forEach(calculatedMetric => {
internalReport.stats.values.push(calculatedMetric.name);
// Treat calculated metrics that are undefined as 0 to ensure graphs
// can be created anyway.
internalReport.stats.values.push(
calculatedMetric.value ? calculatedMetric.value : 0);
});
});
result.push(internalReport);
}
return result;
}
toString() {
let str = '';
for (const stats of this.statsById.values()) {
if (str !== '') {
str += ',';
}
str += JSON.stringify(stats);
}
let str2 = '';
for (const stats of this.calculatedStatsById.values()) {
if (str2 !== '') {
str2 += ',';
}
str2 += stats.toString();
}
return '[original:' + str + '],calculated:[' + str2 + ']';
}
get(id) {
return this.statsById.get(id);
}
getByType(type) {
const result = [];
for (const stats of this.statsById.values()) {
if (stats.type === type) {
result.push(stats);
}
}
return result;
}
addCalculatedMetric(id, insertAtOriginalMetricName, name, value) {
let calculatedStats = this.calculatedStatsById.get(id);
if (!calculatedStats) {
calculatedStats = new CalculatedStats(id);
this.calculatedStatsById.set(id, calculatedStats);
}
calculatedStats.addCalculatedMetric(
insertAtOriginalMetricName, new Metric(name, value));
}
getCalculatedMetrics(id, originalMetricName) {
const calculatedStats = this.calculatedStatsById.get(id);
return calculatedStats ?
calculatedStats.getCalculatedMetrics(originalMetricName) :
[];
}
}
// Shows a `DOMHighResTimeStamp` as a human readable date time.
// The metric must be a time value in milliseconds with Unix epoch as time
// origin.
class DateCalculator {
constructor(metric) {
this.metric = metric;
}
getCalculatedMetricName() {
return '[' + this.metric + ']';
}
calculate(id, previousReport, currentReport) {
const timestamp = currentReport.get(id)[this.metric];
const date = new Date(timestamp);
return date.toLocaleString();
}
}
// Calculates the rate "delta accumulative / delta samples" and returns it. If
// a rate cannot be calculated, such as the metric is missing in the current
// or previous report, undefined is returned.
class RateCalculator {
constructor(
accumulativeMetric, samplesMetric, modifier = CalculatorModifier.kNone) {
this.accumulativeMetric = accumulativeMetric;
this.samplesMetric = samplesMetric;
this.modifier = modifier;
}
getCalculatedMetricName() {
const accumulativeMetric = this.modifier.bitrate ?
this.accumulativeMetric + '_in_bits' :
this.accumulativeMetric;
if (this.samplesMetric === 'timestamp') {
return '[' + accumulativeMetric + '/s]';
}
return '[' + accumulativeMetric + '/' + this.samplesMetric +
this.modifier.postfix + ']';
}
calculate(id, previousReport, currentReport) {
return RateCalculator.calculateRate(
id, previousReport, currentReport, this.accumulativeMetric,
this.samplesMetric) *
this.modifier.multiplier;
}
static calculateRate(
id, previousReport, currentReport, accumulativeMetric, samplesMetric) {
if (!previousReport || !currentReport) {
return undefined;
}
const previousStats = previousReport.get(id);
const currentStats = currentReport.get(id);
if (!previousStats || !currentStats) {
return undefined;
}
const deltaTime = currentStats.timestamp - previousStats.timestamp;
if (deltaTime <= 0) {
return undefined;
}
// Try to convert whatever the values are to numbers. This gets around the
// fact that some types that are not supported by base::Value (e.g. uint32,
// int64, uint64 and double) are passed as strings.
const previousValue = Number(previousStats[accumulativeMetric]);
const currentValue = Number(currentStats[accumulativeMetric]);
if (typeof previousValue !== 'number' || typeof currentValue !== 'number') {
return undefined;
}
const previousSamples = Number(previousStats[samplesMetric]);
const currentSamples = Number(currentStats[samplesMetric]);
if (typeof previousSamples !== 'number' ||
typeof currentSamples !== 'number') {
return undefined;
}
const deltaValue = currentValue - previousValue;
const deltaSamples = currentSamples - previousSamples;
return deltaValue / deltaSamples;
}
}
// Looks up codec and payload type from a codecId reference, constructing an
// informative string about which codec is used.
class CodecCalculator {
getCalculatedMetricName() {
return '[codec]';
}
calculate(id, previousReport, currentReport) {
const targetStats = currentReport.get(id);
const codecStats = currentReport.get(targetStats.codecId);
if (!codecStats) {
return undefined;
}
// If mimeType is 'video/VP8' then codec is 'VP8'.
const codec =
codecStats.mimeType.substr(codecStats.mimeType.indexOf('/') + 1);
let fmtpLine = '';
if (codecStats.sdpFmtpLine) {
fmtpLine = ', ' + codecStats.sdpFmtpLine;
}
return codec + ' (' + codecStats.payloadType + fmtpLine + ')';
}
}
// Calculates "RMS" audio level, which is the average audio level between the
// previous and current report, in the interval [0,1]. Calculated per:
// https://w3c.github.io/webrtc-stats/#dom-rtcinboundrtpstreamstats-totalaudioenergy
class AudioLevelRmsCalculator {
getCalculatedMetricName() {
return '[Audio_Level_in_RMS]';
}
calculate(id, previousReport, currentReport) {
const averageAudioLevelSquared = RateCalculator.calculateRate(
id, previousReport, currentReport, 'totalAudioEnergy',
'totalSamplesDuration');
return Math.sqrt(averageAudioLevelSquared);
}
}
// Calculates "metricA - SUM(otherMetrics)", only looking at the current report.
class DifferenceCalculator {
constructor(metricA, ...otherMetrics) {
this.metricA = metricA;
this.otherMetrics = otherMetrics;
}
getCalculatedMetricName() {
return '[' + this.metricA + '-' + this.otherMetrics.join('-') + ']';
}
calculate(id, previousReport, currentReport) {
const currentStats = currentReport.get(id);
return parseInt(currentStats[this.metricA], 10)
- this.otherMetrics.map(metric => parseInt(currentStats[metric], 10))
.reduce((a, b) => a + b, 0);
}
}
// Calculates the standard deviation from a totalSquaredSum, totalSum, and
// totalCount. If the standard deviation cannot be calculated, such as the
// metric is missing in the current or previous report, undefined is returned.
class StandardDeviationCalculator {
constructor(totalSquaredSumMetric, totalSumMetric, totalCount, label) {
this.totalSquaredSumMetric = totalSquaredSumMetric;
this.totalSumMetric = totalSumMetric;
this.totalCount = totalCount;
this.label = label;
}
getCalculatedMetricName() {
return '[' + this.label + 'StDev_in_ms]';
}
calculate(id, previousReport, currentReport) {
return StandardDeviationCalculator.calculateStandardDeviation(
id, previousReport, currentReport, this.totalSquaredSumMetric,
this.totalSumMetric, this.totalCount);
}
static calculateStandardDeviation(
id, previousReport, currentReport, totalSquaredSumMetric, totalSumMetric,
totalCount) {
if (!previousReport || !currentReport) {
return undefined;
}
const previousStats = previousReport.get(id);
const currentStats = currentReport.get(id);
if (!previousStats || !currentStats) {
return undefined;
}
const deltaCount =
Number(currentStats[totalCount]) - Number(previousStats[totalCount]);
if (deltaCount <= 0) {
return undefined;
}
// Try to convert whatever the values are to numbers. This gets around the
// fact that some types that are not supported by base::Value (e.g. uint32,
// int64, uint64 and double) are passed as strings.
const previousSquaredSumValue =
Number(previousStats[totalSquaredSumMetric]);
const currentSquaredSumValue = Number(currentStats[totalSquaredSumMetric]);
if (typeof previousSquaredSumValue !== 'number' ||
typeof currentSquaredSumValue !== 'number') {
return undefined;
}
const previousSumValue = Number(previousStats[totalSumMetric]);
const currentSumValue = Number(currentStats[totalSumMetric]);
if (typeof previousSumValue !== 'number' ||
typeof currentSumValue !== 'number') {
return undefined;
}
const deltaSquaredSum = currentSquaredSumValue - previousSquaredSumValue;
const deltaSum = currentSumValue - previousSumValue;
const variance =
(deltaSquaredSum - Math.pow(deltaSum, 2) / deltaCount) / deltaCount;
if (variance < 0) {
return undefined;
}
return 1000 * Math.sqrt(variance);
}
}
// Keeps track of previous and current stats report and calculates all
// calculated metrics.
export class StatsRatesCalculator {
constructor() {
this.previousReport = null;
this.currentReport = null;
this.statsCalculators = [
{
type: 'data-channel',
metricCalculators: {
messagesSent: new RateCalculator('messagesSent', 'timestamp'),
messagesReceived: new RateCalculator('messagesReceived', 'timestamp'),
bytesSent: new RateCalculator(
'bytesSent', 'timestamp', CalculatorModifier.kBytesToBits),
bytesReceived: new RateCalculator(
'bytesReceived', 'timestamp', CalculatorModifier.kBytesToBits),
},
},
{
type: 'media-source',
metricCalculators: {
totalAudioEnergy: new AudioLevelRmsCalculator(),
},
},
{
type: 'outbound-rtp',
metricCalculators: {
bytesSent: new RateCalculator(
'bytesSent', 'timestamp', CalculatorModifier.kBytesToBits),
headerBytesSent: new RateCalculator(
'headerBytesSent', 'timestamp', CalculatorModifier.kBytesToBits),
retransmittedBytesSent: new RateCalculator(
'retransmittedBytesSent', 'timestamp',
CalculatorModifier.kBytesToBits),
packetsSent: new RateCalculator('packetsSent', 'timestamp'),
retransmittedPacketsSent:
new RateCalculator('retransmittedPacketsSent', 'timestamp'),
totalPacketSendDelay: new RateCalculator(
'totalPacketSendDelay', 'packetsSent',
CalculatorModifier.kMillisecondsFromSeconds),
framesEncoded: new RateCalculator('framesEncoded', 'timestamp'),
framesSent: new RateCalculator('framesSent', 'timestamp'),
totalEncodedBytesTarget: new RateCalculator(
'totalEncodedBytesTarget', 'timestamp',
CalculatorModifier.kBytesToBits),
totalEncodeTime: new RateCalculator(
'totalEncodeTime', 'framesEncoded',
CalculatorModifier.kMillisecondsFromSeconds),
qpSum: new RateCalculator('qpSum', 'framesEncoded'),
codecId: new CodecCalculator(),
},
},
{
type: 'inbound-rtp',
metricCalculators: {
bytesReceived: new RateCalculator(
'bytesReceived', 'timestamp', CalculatorModifier.kBytesToBits),
headerBytesReceived: new RateCalculator(
'headerBytesReceived', 'timestamp',
CalculatorModifier.kBytesToBits),
retransmittedBytesReceived: new RateCalculator(
'retransmittedBytesReceived', 'timestamp',
CalculatorModifier.kBytesToBits),
fecBytesReceived: new RateCalculator(
'fecBytesReceived', 'timestamp',
CalculatorModifier.kBytesToBits),
packetsReceived: new RateCalculator('packetsReceived', 'timestamp'),
packetsDiscarded: new RateCalculator('packetsDiscarded', 'timestamp'),
retransmittedPacketsReceived:
new RateCalculator('retransmittedPacketsReceived', 'timestamp'),
fecPacketsReceived:
new RateCalculator('fecPacketsReceived', 'timestamp'),
fecPacketsDiscarded:
new RateCalculator('fecPacketsDiscarded', 'timestamp'),
framesReceived: [
new RateCalculator('framesReceived', 'timestamp'),
new DifferenceCalculator('framesReceived',
'framesDecoded', 'framesDropped'),
],
framesDecoded: new RateCalculator('framesDecoded', 'timestamp'),
keyFramesDecoded: new RateCalculator('keyFramesDecoded', 'timestamp'),
totalDecodeTime: new RateCalculator(
'totalDecodeTime', 'framesDecoded',
CalculatorModifier.kMillisecondsFromSeconds),
totalInterFrameDelay: new RateCalculator(
'totalInterFrameDelay', 'framesDecoded',
CalculatorModifier.kMillisecondsFromSeconds),
totalSquaredInterFrameDelay: new StandardDeviationCalculator(
'totalSquaredInterFrameDelay', 'totalInterFrameDelay',
'framesDecoded', 'interFrameDelay'),
totalSamplesReceived:
new RateCalculator('totalSamplesReceived', 'timestamp'),
concealedSamples: [
new RateCalculator('concealedSamples', 'timestamp'),
new RateCalculator('concealedSamples', 'totalSamplesReceived'),
],
silentConcealedSamples:
new RateCalculator('silentConcealedSamples', 'timestamp'),
insertedSamplesForDeceleration:
new RateCalculator('insertedSamplesForDeceleration', 'timestamp'),
removedSamplesForAcceleration:
new RateCalculator('removedSamplesForAcceleration', 'timestamp'),
qpSum: new RateCalculator('qpSum', 'framesDecoded'),
codecId: new CodecCalculator(),
totalAudioEnergy: new AudioLevelRmsCalculator(),
jitterBufferDelay: new RateCalculator(
'jitterBufferDelay', 'jitterBufferEmittedCount',
CalculatorModifier.kMillisecondsFromSeconds),
jitterBufferTargetDelay: new RateCalculator(
'jitterBufferTargetDelay', 'jitterBufferEmittedCount',
CalculatorModifier.kMillisecondsFromSeconds),
jitterBufferMinimumDelay: new RateCalculator(
'jitterBufferMinimumDelay', 'jitterBufferEmittedCount',
CalculatorModifier.kMillisecondsFromSeconds),
lastPacketReceivedTimestamp: new DateCalculator(
'lastPacketReceivedTimestamp'),
estimatedPlayoutTimestamp: new DateCalculator(
'estimatedPlayoutTimestamp'),
totalProcessingDelay: new RateCalculator(
'totalProcessingDelay', 'framesDecoded',
CalculatorModifier.kMillisecondsFromSeconds),
totalAssemblyTime: new RateCalculator(
'totalAssemblyTime', 'framesAssembledFromMultiplePackets',
CalculatorModifier.kMillisecondsFromSeconds),
},
},
{
type: 'remote-outbound-rtp',
metricCalculators: {
remoteTimestamp: new DateCalculator('remoteTimestamp'),
},
},
{
type: 'transport',
metricCalculators: {
bytesSent: new RateCalculator(
'bytesSent', 'timestamp', CalculatorModifier.kBytesToBits),
bytesReceived: new RateCalculator(
'bytesReceived', 'timestamp', CalculatorModifier.kBytesToBits),
packetsSent: new RateCalculator(
'packetsSent', 'timestamp'),
packetsReceived: new RateCalculator(
'packetsReceived', 'timestamp'),
},
},
{
type: 'candidate-pair',
metricCalculators: {
bytesSent: new RateCalculator(
'bytesSent', 'timestamp', CalculatorModifier.kBytesToBits),
bytesReceived: new RateCalculator(
'bytesReceived', 'timestamp', CalculatorModifier.kBytesToBits),
packetsSent: new RateCalculator(
'packetsSent', 'timestamp'),
packetsReceived: new RateCalculator(
'packetsReceived', 'timestamp'),
totalRoundTripTime:
new RateCalculator('totalRoundTripTime', 'responsesReceived'),
lastPacketReceivedTimestamp: new DateCalculator(
'lastPacketReceivedTimestamp'),
lastPacketSentTimestamp: new DateCalculator(
'lastPacketSentTimestamp'),
},
},
];
}
addStatsReport(report) {
this.previousReport = this.currentReport;
this.currentReport = report;
this.updateCalculatedMetrics_();
}
// Updates all "calculated metrics", which are metrics derived from standard
// values, such as converting total counters (e.g. bytesSent) to rates (e.g.
// bytesSent/s).
updateCalculatedMetrics_() {
this.statsCalculators.forEach(statsCalculator => {
this.currentReport.getByType(statsCalculator.type).forEach(stats => {
Object.keys(statsCalculator.metricCalculators)
.forEach(originalMetric => {
let metricCalculators =
statsCalculator.metricCalculators[originalMetric];
if (!Array.isArray(metricCalculators)) {
metricCalculators = [metricCalculators];
}
metricCalculators.forEach(metricCalculator => {
this.currentReport.addCalculatedMetric(
stats.id, originalMetric,
metricCalculator.getCalculatedMetricName(),
metricCalculator.calculate(
stats.id, this.previousReport, this.currentReport));
});
});
});
});
}
}