import { type MediaTrackStats } from "./media/mediaTrackStats.ts"; import { ValueFormatter } from "./valueFormatter.ts"; import { type TrackSummary } from "./callStatsReportSummary.ts"; export class TrackStatsBuilder { public static buildFramerateResolution(trackStats: MediaTrackStats, now: any): void { const resolution = { height: now.frameHeight, width: now.frameWidth, }; const frameRate = now.framesPerSecond; if (resolution.height && resolution.width) { trackStats.setResolution(resolution); } trackStats.setFramerate(Math.round(frameRate || 0)); } public static calculateSimulcastFramerate(trackStats: MediaTrackStats, now: any, before: any, layer: number): void { let frameRate = trackStats.getFramerate(); if (!frameRate) { 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; } } // Reset frame rate to 0 when video is suspended as a result of endpoint falling out of last-n. frameRate = layer ? Math.round(frameRate / layer) : 0; trackStats.setFramerate(frameRate); } public static buildCodec(report: RTCStatsReport | undefined, trackStats: MediaTrackStats, now: any): void { const codec = report?.get(now.codecId); if (codec) { /** * The mime type has the following form: video/VP8 or audio/ISAC, * so we what to keep just the type after the '/', audio and video * keys will be added on the processing side. */ const codecShortType = codec.mimeType.split("/")[1]; if (codecShortType) trackStats.setCodec(codecShortType); } } public static buildBitrateReceived(trackStats: MediaTrackStats, now: any, before: any): void { trackStats.setBitrate({ download: TrackStatsBuilder.calculateBitrate( now.bytesReceived, before.bytesReceived, now.timestamp, before.timestamp, ), upload: 0, }); } public static buildBitrateSend(trackStats: MediaTrackStats, now: any, before: any): void { trackStats.setBitrate({ download: 0, upload: this.calculateBitrate(now.bytesSent, before.bytesSent, now.timestamp, before.timestamp), }); } public static buildPacketsLost(trackStats: MediaTrackStats, now: any, before: any): void { const key = now.type === "outbound-rtp" ? "packetsSent" : "packetsReceived"; let packetsNow = now[key]; if (!packetsNow || packetsNow < 0) { packetsNow = 0; } const packetsBefore = ValueFormatter.getNonNegativeValue(before[key]); const packetsDiff = Math.max(0, packetsNow - packetsBefore); const packetsLostNow = ValueFormatter.getNonNegativeValue(now.packetsLost); const packetsLostBefore = ValueFormatter.getNonNegativeValue(before.packetsLost); const packetsLostDiff = Math.max(0, packetsLostNow - packetsLostBefore); trackStats.setLoss({ packetsTotal: packetsDiff + packetsLostDiff, packetsLost: packetsLostDiff, isDownloadStream: now.type !== "outbound-rtp", }); } private static calculateBitrate( bytesNowAny: any, bytesBeforeAny: any, nowTimestamp: number, beforeTimestamp: number, ): number { const bytesNow = ValueFormatter.getNonNegativeValue(bytesNowAny); const bytesBefore = ValueFormatter.getNonNegativeValue(bytesBeforeAny); const bytesProcessed = Math.max(0, bytesNow - bytesBefore); const timeMs = nowTimestamp - beforeTimestamp; let bitrateKbps = 0; if (timeMs > 0) { bitrateKbps = Math.round((bytesProcessed * 8) / timeMs); } return bitrateKbps; } public static setTrackStatsState(trackStats: MediaTrackStats, transceiver: RTCRtpTransceiver | undefined): void { if (transceiver === undefined) { trackStats.alive = false; return; } const track = trackStats.getType() === "remote" ? transceiver.receiver.track : transceiver?.sender?.track; if (track === undefined || track === null) { trackStats.alive = false; return; } if (track.readyState === "ended") { trackStats.alive = false; return; } trackStats.muted = track.muted; trackStats.enabled = track.enabled; trackStats.alive = true; } public static buildTrackSummary(trackStatsList: MediaTrackStats[]): { audioTrackSummary: TrackSummary; videoTrackSummary: TrackSummary; } { const videoTrackSummary: TrackSummary = { count: 0, muted: 0, maxJitter: 0, maxPacketLoss: 0, concealedAudio: 0, totalAudio: 0, }; const audioTrackSummary: TrackSummary = { count: 0, muted: 0, maxJitter: 0, maxPacketLoss: 0, concealedAudio: 0, totalAudio: 0, }; const remoteTrackList = trackStatsList.filter((t) => t.getType() === "remote"); const audioTrackList = remoteTrackList.filter((t) => t.kind === "audio"); remoteTrackList.forEach((stats) => { const trackSummary = stats.kind === "video" ? videoTrackSummary : audioTrackSummary; trackSummary.count++; if (stats.alive && stats.muted) { trackSummary.muted++; } if (trackSummary.maxJitter < stats.getJitter()) { trackSummary.maxJitter = stats.getJitter(); } if (trackSummary.maxPacketLoss < stats.getLoss().packetsLost) { trackSummary.maxPacketLoss = stats.getLoss().packetsLost; } if (audioTrackList.length > 0) { trackSummary.concealedAudio += stats.getAudioConcealment()?.concealedAudio; trackSummary.totalAudio += stats.getAudioConcealment()?.totalAudioDuration; } }); return { audioTrackSummary, videoTrackSummary }; } public static buildJitter(trackStats: MediaTrackStats, statsReport: any): void { if (statsReport.type !== "inbound-rtp") { return; } const jitterStr = statsReport?.jitter; if (jitterStr !== undefined) { const jitter = ValueFormatter.getNonNegativeValue(jitterStr); trackStats.setJitter(Math.round(jitter * 1000)); } else { trackStats.setJitter(-1); } } public static buildAudioConcealment(trackStats: MediaTrackStats, statsReport: any): void { if (statsReport.type !== "inbound-rtp") { return; } const msPerSample = (1000 * statsReport?.totalSamplesDuration) / statsReport?.totalSamplesReceived; const concealedAudioDuration = msPerSample * statsReport?.concealedSamples; const totalAudioDuration = 1000 * statsReport?.totalSamplesDuration; trackStats.setAudioConcealment(concealedAudioDuration, totalAudioDuration); } }