@100mslive/hms-video-store
Version:
@100mslive Core SDK which abstracts the complexities of webRTC while providing a reactive store for data management with a unidirectional data flow
460 lines (425 loc) • 14.5 kB
text/typescript
import AnalyticsEventFactory from '../analytics/AnalyticsEventFactory';
import { ErrorFactory } from '../error/ErrorFactory';
import { HMSAction } from '../error/HMSAction';
import { EventBus } from '../events/EventBus';
import { RID } from '../interfaces';
import {
HMSLocalTrackStats,
HMSPeerStats,
HMSTrackStats,
MissingInboundStats,
PeerConnectionType,
RTCRemoteInboundRtpStreamStats,
} from '../interfaces/webrtc-stats';
import { HMSLocalTrack, HMSRemoteTrack, HMSTrackType } from '../media/tracks';
import HMSLogger from '../utils/logger';
import { isPresent } from '../utils/validations';
interface MediaSourceStats {
frames?: number;
framesPerSecond?: number;
framesDropped?: number;
width?: number;
height?: number;
timestamp?: DOMHighResTimeStamp;
}
const isVideoMediaSourceStat = (stat: any): boolean => {
const kind = stat.kind || stat.mediaType;
return !kind || kind === 'video';
};
const matchesSenderTrack = (stat: any, senderTrackId?: string): boolean => {
if (!senderTrackId || !stat.trackIdentifier) {
return true;
}
return stat.trackIdentifier === senderTrackId;
};
const extractMediaSourceStats = (stat: any): MediaSourceStats => {
return {
frames: stat.frames,
framesPerSecond: stat.framesPerSecond,
framesDropped: stat.framesDropped,
width: stat.width ?? stat.frameWidth,
height: stat.height ?? stat.frameHeight,
timestamp: stat.timestamp,
};
};
const computeSourceFrameRateFromFrames = (
mediaSourceStats: MediaSourceStats,
prevTrackStats?: HMSTrackStats,
): number | undefined => {
if (
!isPresent(mediaSourceStats.frames) ||
!isPresent(prevTrackStats?.sourceFrames) ||
!isPresent(mediaSourceStats.timestamp) ||
!isPresent(prevTrackStats?.sourceTimestamp)
) {
return undefined;
}
return (
computeNumberRate(
mediaSourceStats.frames,
prevTrackStats?.sourceFrames,
mediaSourceStats.timestamp,
prevTrackStats?.sourceTimestamp,
) * 1000
);
};
const resolveSourceFramesPerSecond = (
mediaSourceStats: MediaSourceStats,
prevTrackStats?: HMSTrackStats,
): number | undefined => {
if (isPresent(mediaSourceStats.framesPerSecond)) {
return mediaSourceStats.framesPerSecond;
}
return computeSourceFrameRateFromFrames(mediaSourceStats, prevTrackStats);
};
const normalizeQualityLimitationDurations = (
value?: Record<string, number>,
): { none: number; cpu: number; bandwidth: number; other: number } | undefined => {
if (!value) {
return undefined;
}
return {
none: value.none || 0,
cpu: value.cpu || 0,
bandwidth: value.bandwidth || 0,
other: value.other || 0,
};
};
const getTrackSourceStats = (
trackReport: RTCStatsReport | undefined,
track: HMSLocalTrack,
): MediaSourceStats | undefined => {
if (!trackReport) {
return undefined;
}
const senderTrackId = track.transceiver?.sender?.track?.id;
for (const stat of trackReport.values()) {
if (stat.type !== 'track') {
continue;
}
const trackStat = stat as any;
if (!isVideoMediaSourceStat(trackStat)) {
continue;
}
if (!matchesSenderTrack(trackStat, senderTrackId)) {
continue;
}
return extractMediaSourceStats(trackStat);
}
return undefined;
};
const resolveSourceStats = (
trackReport: RTCStatsReport | undefined,
track: HMSLocalTrack,
): MediaSourceStats | undefined => {
return getMediaSourceStats(trackReport, track) || getTrackSourceStats(trackReport, track);
};
export const getLocalTrackStats = async (
eventBus: EventBus,
track: HMSLocalTrack,
peerName?: string,
prevTrackStats?: Record<string, HMSTrackStats>,
): Promise<Record<string, HMSTrackStats> | undefined> => {
let trackReport: RTCStatsReport | undefined;
const trackStats: Record<string, HMSTrackStats> = {};
if (!track.transceiver?.sender.track) {
return;
}
try {
trackReport = await track.transceiver.sender.getStats();
const mimeTypes: { [key: string]: string } = {}; // codecId -> mimeType
const outbound: Record<string, RTCOutboundRtpStreamStats> = {};
const inbound: Record<string, RTCInboundRtpStreamStats & MissingInboundStats> = {};
const mediaSourceStats = track.type === HMSTrackType.VIDEO ? resolveSourceStats(trackReport, track) : undefined;
trackReport?.forEach(stat => {
switch (stat.type) {
case 'outbound-rtp':
outbound[stat.id] = stat;
break;
case 'remote-inbound-rtp':
inbound[stat.ssrc] = stat;
break;
case 'codec':
mimeTypes[stat.id] = stat.mimeType;
break;
default:
break;
}
});
Object.keys({ ...outbound }).forEach(stat => {
const codecId = outbound[stat]?.codecId;
const mimeType = codecId ? mimeTypes[codecId] : undefined;
let codec: string | undefined;
if (mimeType) {
codec = mimeType.substring(mimeType.indexOf('/') + 1);
}
const out = { ...outbound[stat], rid: (outbound[stat] as HMSLocalTrackStats)?.rid as RID | undefined };
const qualityLimitationDurations = normalizeQualityLimitationDurations((out as any).qualityLimitationDurations);
const outStats = { ...out, qualityLimitationDurations };
const outboundStats = outStats as Partial<HMSTrackStats>;
const trackIdentifier =
(outStats as any).trackIdentifier ?? track.transceiver?.sender?.track?.id ?? track.trackId;
const inStats = inbound[out.ssrc];
const sourceStats =
track.type === HMSTrackType.VIDEO
? buildMediaSourceStats(mediaSourceStats, (outStats as any).framesEncoded, prevTrackStats?.[stat])
: {};
trackStats[stat] = {
...outStats,
...sourceStats,
trackIdentifier,
bitrate: computeBitrate('bytesSent', outboundStats, prevTrackStats?.[stat]),
packetsLost: inStats?.packetsLost,
jitter: inStats?.jitter,
roundTripTime: inStats?.roundTripTime,
totalRoundTripTime: inStats?.totalRoundTripTime,
peerName,
peerID: track.peerId,
enabled: track.enabled,
codec,
};
});
} catch (err: any) {
eventBus.analytics.publish(
AnalyticsEventFactory.rtcStatsFailed(
ErrorFactory.WebrtcErrors.StatsFailed(
HMSAction.TRACK,
`Error getting local track stats ${track.trackId} - ${err.message}`,
),
),
);
HMSLogger.w('[HMSWebrtcStats]', 'Error in getting local track stats', track, err, (err as Error).name);
}
return trackStats;
};
export const getTrackStats = async (
eventBus: EventBus,
track: HMSRemoteTrack,
peerName?: string,
prevTrackStats?: HMSTrackStats,
): Promise<HMSTrackStats | undefined> => {
let trackReport: RTCStatsReport | undefined;
try {
trackReport = await track.transceiver?.receiver.getStats();
} catch (err: any) {
eventBus.analytics.publish(
AnalyticsEventFactory.rtcStatsFailed(
ErrorFactory.WebrtcErrors.StatsFailed(
HMSAction.TRACK,
`Error getting remote track stats ${track.trackId} - ${err.message}`,
),
),
);
HMSLogger.w('[HMSWebrtcStats]', 'Error in getting remote track stats', track, err);
}
const trackStats = getRelevantStatsFromTrackReport(trackReport);
const reportStats = trackStats as Partial<HMSTrackStats> | undefined;
const bitrate = computeBitrate('bytesReceived', reportStats, prevTrackStats);
const packetsLostRate = computeStatRate('packetsLost', reportStats, prevTrackStats);
if (trackStats?.remote) {
Object.assign(trackStats.remote, {
packetsLostRate: computeStatRate('packetsLost', trackStats.remote, prevTrackStats?.remote),
});
}
return (
trackStats && {
...trackStats,
trackIdentifier: (trackStats as any).trackIdentifier ?? track.transceiver?.receiver?.track?.id ?? track.trackId,
bitrate,
packetsLostRate,
peerID: track.peerId,
enabled: track.enabled,
peerName,
codec: trackStats.codec,
}
);
};
const getRelevantStatsFromTrackReport = (trackReport?: RTCStatsReport) => {
let streamStats: RTCInboundRtpStreamStats | (RTCOutboundRtpStreamStats & { rid?: RID }) | undefined;
// Valid by Webrtc spec, not in TS
// let remoteStreamStats: RTCRemoteInboundRtpStreamStats | RTCRemoteOutboundRtpStreamStats;
let remoteStreamStats: RTCRemoteInboundRtpStreamStats | undefined;
const mimeTypes: { [key: string]: string } = {}; // codecId -> mimeType
trackReport?.forEach(stat => {
switch (stat.type) {
case 'inbound-rtp':
streamStats = stat;
break;
case 'outbound-rtp':
streamStats = stat;
break;
case 'remote-inbound-rtp':
remoteStreamStats = stat;
break;
case 'codec':
mimeTypes[stat.id] = stat.mimeType;
break;
default:
break;
}
});
const mimeType = streamStats?.codecId ? mimeTypes[streamStats.codecId] : undefined;
let codec: string | undefined;
if (mimeType) {
codec = mimeType.substring(mimeType.indexOf('/') + 1);
}
if (!streamStats) {
return undefined;
}
const qualityLimitationDurations = normalizeQualityLimitationDurations(
(streamStats as any).qualityLimitationDurations,
);
return Object.assign(streamStats, {
remote: remoteStreamStats,
codec: codec,
...(qualityLimitationDurations ? { qualityLimitationDurations } : {}),
});
};
const getMediaSourceStats = (
trackReport: RTCStatsReport | undefined,
track: HMSLocalTrack,
): MediaSourceStats | undefined => {
if (!trackReport) {
return undefined;
}
const senderTrackId = track.transceiver?.sender?.track?.id;
for (const stat of trackReport.values()) {
if (stat.type !== 'media-source') {
continue;
}
const mediaStat = stat as any;
if (!isVideoMediaSourceStat(mediaStat)) {
continue;
}
if (!matchesSenderTrack(mediaStat, senderTrackId)) {
continue;
}
return extractMediaSourceStats(mediaStat);
}
return undefined;
};
const computeSourceFramesDropped = (
sourceFrames: number | undefined,
framesEncoded: number | undefined,
prevSourceFrames: number | undefined,
prevFramesEncoded: number | undefined,
): number | undefined => {
if (
!isPresent(sourceFrames) ||
!isPresent(framesEncoded) ||
!isPresent(prevSourceFrames) ||
!isPresent(prevFramesEncoded)
) {
return undefined;
}
const framesCapturedDiff = (sourceFrames as number) - (prevSourceFrames as number);
const framesEncodedDiff = (framesEncoded as number) - (prevFramesEncoded as number);
return Math.max(0, framesCapturedDiff - framesEncodedDiff);
};
const buildMediaSourceStats = (
mediaSourceStats: MediaSourceStats | undefined,
framesEncoded: number | undefined,
prevTrackStats?: HMSTrackStats,
): Partial<HMSLocalTrackStats> => {
const sourceFramesDropped = computeSourceFramesDropped(
mediaSourceStats?.frames,
framesEncoded,
prevTrackStats?.sourceFrames,
prevTrackStats?.framesEncoded,
);
return {
sourceFrameWidth: mediaSourceStats?.width,
sourceFrameHeight: mediaSourceStats?.height,
sourceFrames: mediaSourceStats?.frames,
sourceFramesDropped,
sourceFramesPerSecond: mediaSourceStats
? resolveSourceFramesPerSecond(mediaSourceStats, prevTrackStats)
: undefined,
sourceTimestamp: mediaSourceStats?.timestamp,
sourceStatsAvailable: Boolean(mediaSourceStats),
};
};
export const getLocalPeerStatsFromReport = (
type: PeerConnectionType,
report?: RTCStatsReport,
prevStats?: HMSPeerStats,
): (RTCIceCandidatePairStats & { bitrate: number }) | undefined => {
const activeCandidatePair = getActiveCandidatePairFromReport(report);
const bitrate = computeBitrate(
(type === 'publish' ? 'bytesSent' : 'bytesReceived') as any,
activeCandidatePair,
prevStats && prevStats[type],
);
return activeCandidatePair && Object.assign(activeCandidatePair, { bitrate });
};
export const getActiveCandidatePairFromReport = (report?: RTCStatsReport): RTCIceCandidatePairStats | undefined => {
let activeCandidatePair: RTCIceCandidatePairStats | undefined;
report?.forEach(stat => {
if (stat.type === 'transport') {
// TS doesn't have correct types for RTCStatsReports
activeCandidatePair = report?.get(stat.selectedCandidatePairId);
}
});
// Fallback for Firefox.
if (!activeCandidatePair) {
report?.forEach(stat => {
if (stat.type === 'candidate-pair' && stat.selected) {
activeCandidatePair = stat;
}
});
}
return activeCandidatePair;
};
export const getPacketsLostAndJitterFromReport = (report?: RTCStatsReport): { packetsLost: number; jitter: number } => {
const result = { packetsLost: 0, jitter: 0 };
report?.forEach(stat => {
if (stat.packetsLost) {
result.packetsLost += stat.packetsLost;
}
if (stat.jitter > result.jitter) {
result.jitter = stat.jitter;
}
});
return result;
};
export const union = <T>(arr1: T[], arr2: T[]): T[] => {
return Array.from(new Set(arr1.concat(arr2)));
};
/**
* Ref: https://github.dev/peermetrics/webrtc-stats/blob/b5c1fed68325543e6f563c6d3f4450a4b51e12b7/src/utils.ts#L62
*/
export const computeBitrate = <T extends HMSTrackStats>(
statName: keyof T,
newReport?: Partial<T>,
oldReport?: Partial<T>,
): number => computeStatRate(statName, newReport, oldReport) * 8; // Bytes to bits
const computeStatRate = <T extends HMSTrackStats>(
statName: keyof T,
newReport?: Partial<T>,
oldReport?: Partial<T>,
): number => {
const newVal = newReport && newReport[statName];
const oldVal = oldReport ? oldReport[statName] : null;
const conditions = [newReport, oldReport, isPresent(newVal), isPresent(oldVal)];
if (conditions.every(condition => !!condition)) {
// Type not null checked in `isPresent`
// * 1000 - ms to s
return (
computeNumberRate(
newVal as unknown as number,
oldVal as unknown as number,
newReport?.timestamp,
oldReport?.timestamp,
) * 1000
);
} else {
return 0;
}
};
export const computeNumberRate = (newVal?: number, oldVal?: number, newTimestamp?: number, oldTimestamp?: number) => {
if (isPresent(newVal) && isPresent(oldVal) && newTimestamp && oldTimestamp) {
return ((newVal as number) - (oldVal as number)) / (newTimestamp - oldTimestamp);
} else {
return 0;
}
};