Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ bandwidthRtc.sendDtmf("313,3211*#");
bandwidthRtc.sendDtmf("5", undefined, 200, 100); // 200ms tone, 100ms gap
```

### getCallStats

- Description: returns a `CallStatsSnapshot` parsed from native WebRTC stats (v1 client). Pass the previous snapshot to get `inboundBitrate`/`outboundBitrate` (bits/s).
- Fields: `packetsReceived`, `packetsLost`, `bytesReceived`, `jitter`, `audioLevel`, `packetsSent`, `bytesSent`, `roundTripTime`, `codec`, `inboundBitrate`, `outboundBitrate`, `timestamp` (seconds)
- RTCP data from the remote receiver: `remoteFractionLost`, `remoteJitter`, `rtcpRoundTripTime`

## Event Listeners

### onStreamAvailable
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { default } from "./bandwidthRtc";
export { BandwidthRtc as BandwidthRtcV1 } from "./v1/bandwidthRtc";
export * from "./types";
export type { CallStatsSnapshot } from "./v1/types";
114 changes: 114 additions & 0 deletions src/v1/bandwidthRtc.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { BandwidthRtc } from "./bandwidthRtc";
import { setupMocks, setupNavigatorMocks } from "../mocks";
import { BandwidthRtcError } from "../types";
import logger from "../logging";

// Mock Signaling class
jest.mock("./signaling", () => {
Expand Down Expand Up @@ -1158,3 +1159,116 @@ describe("bandwidthRtcV1 onconnectionstatechange wiring", () => {
expect(offerPublishSdp).not.toHaveBeenCalled();
});
});

describe("bandwidthRtcV1 getCallStats", () => {
const report = (entries: any[]) => new Map(entries.map((e) => [e.id, e]));
const subReport = report([
{ id: "in", type: "inbound-rtp", kind: "audio", packetsReceived: 10, packetsLost: 1, bytesReceived: 2000, jitter: 0.01, audioLevel: 0.5, codecId: "c" },
{ id: "c", type: "codec", mimeType: "audio/opus" },
{ id: "cp", type: "candidate-pair", state: "succeeded", currentRoundTripTime: 0.05 },
]);
const pubReport = report([
{ id: "out", type: "outbound-rtp", kind: "audio", packetsSent: 20, bytesSent: 4000 },
{ id: "r", type: "remote-inbound-rtp", kind: "audio", fractionLost: 0.1, jitter: 0.02, roundTripTime: 0.08 },
]);
const setup = (sub: any, pub: any) => {
const brtc = new BandwidthRtc();
(brtc as any).subscribingPeerConnection = sub && { getStats: sub };
(brtc as any).publishingPeerConnection = pub && { getStats: pub };
return brtc;
};

test("parses both reports including RTCP fields", async () => {
const snap = await setup(jest.fn().mockResolvedValue(subReport), jest.fn().mockResolvedValue(pubReport)).getCallStats();
expect(snap).toMatchObject({
packetsReceived: 10,
packetsLost: 1,
bytesReceived: 2000,
jitter: 0.01,
audioLevel: 0.5,
roundTripTime: 0.05,
codec: "opus",
packetsSent: 20,
bytesSent: 4000,
remoteFractionLost: 0.1,
remoteJitter: 0.02,
rtcpRoundTripTime: 0.08,
inboundBitrate: 0,
outboundBitrate: 0,
});
expect(snap.timestamp).toBeGreaterThan(0);
});

test("computes bitrate from previous snapshot and clamps negative deltas", async () => {
const brtc = setup(jest.fn().mockResolvedValue(subReport), jest.fn().mockResolvedValue(pubReport));
const now = Date.now() / 1000;
const prev: any = { bytesReceived: 1000, bytesSent: 9000, timestamp: now - 2 };
const snap = await brtc.getCallStats(prev);
expect(snap.inboundBitrate).toBeCloseTo(4000, -2);
expect(snap.outboundBitrate).toBe(0);
});

test("returns defaults when peer connections are undefined", async () => {
const snap = await setup(undefined, undefined).getCallStats();
expect(snap.codec).toBe("unknown");
expect(snap.packetsReceived).toBe(0);
expect(snap.timestamp).toBeGreaterThan(0);
});

test("keeps defaults for a side whose getStats rejects", async () => {
const snap = await setup(jest.fn().mockRejectedValue(new Error("boom")), jest.fn().mockResolvedValue(pubReport)).getCallStats();
expect(snap.packetsReceived).toBe(0);
expect(snap.bytesSent).toBe(4000);
});
});

describe("bandwidthRtcV1 call stats trace", () => {
const TRACE_INTERVAL_MS = 5 * 60 * 1000;
const pc = () => ({ getStats: jest.fn().mockResolvedValue(new Map()), close: jest.fn() });

beforeEach(() => {
jest.useFakeTimers();
jest.spyOn(logger, "debug").mockImplementation(() => {});
});

afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});

const traceCalls = () => (logger.debug as jest.Mock).mock.calls.filter(([msg]) => msg === "Call stats");

test("logs a snapshot every 5 minutes after connect", async () => {
const brtc = new BandwidthRtc();
(brtc as any).publishingPeerConnection = pc();
await brtc.connect({ endpointToken: "t" });

await jest.advanceTimersByTimeAsync(TRACE_INTERVAL_MS - 1);
expect(traceCalls()).toHaveLength(0);
await jest.advanceTimersByTimeAsync(1);
expect(traceCalls()).toHaveLength(1);
expect(traceCalls()[0][1]).toMatchObject({ codec: "unknown", rtcpRoundTripTime: 0 });
await jest.advanceTimersByTimeAsync(TRACE_INTERVAL_MS);
expect(traceCalls()).toHaveLength(2);
brtc.disconnect();
});

test("stops tracing after disconnect", async () => {
const brtc = new BandwidthRtc();
(brtc as any).publishingPeerConnection = pc();
await brtc.connect({ endpointToken: "t" });
brtc.disconnect();

await jest.advanceTimersByTimeAsync(TRACE_INTERVAL_MS * 2);
expect(traceCalls()).toHaveLength(0);
});

test("skips the trace while no peer connection exists", async () => {
const brtc = new BandwidthRtc();
await brtc.connect({ endpointToken: "t" });

await jest.advanceTimersByTimeAsync(TRACE_INTERVAL_MS);
expect(traceCalls()).toHaveLength(0);
brtc.disconnect();
});
});
107 changes: 107 additions & 0 deletions src/v1/bandwidthRtc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import { DiagnosticsBatcher } from "./diagnostics";
import Signaling from "./signaling";
import {
CallStatsSnapshot,
CodecPreferences,
ReadyMetadata,
PublishedStream,
Expand Down Expand Up @@ -66,12 +67,19 @@ const PUBLISH_ICE_CONNECT_POLL_INTERVAL_MS = 100;
// The gateway owns ICE restart and rejects client offers until the peer is connected again.
const RETRY_ICE_ON_FAILED = false;

/** How often the call stats snapshot is written to the debug log while connected. */
const CALL_STATS_TRACE_INTERVAL_MS = 5 * 60 * 1000;

export class BandwidthRtc {
private options?: RtcOptions;

private diagnosticsBatcher: DiagnosticsBatcher;
private signaling: Signaling;

private callStatsTraceTimer?: ReturnType<typeof setInterval>;
// Previous traced snapshot; lets each trace carry bitrates computed over the trace interval
private lastTracedCallStats?: CallStatsSnapshot;

// One peer connection for all published (outgoing) streams, one for all subscribed (incoming) streams
private publishingPeerConnection?: RTCPeerConnection;
private subscribingPeerConnection?: RTCPeerConnection;
Expand Down Expand Up @@ -150,6 +158,7 @@ export class BandwidthRtc {

await this.signaling.connect(authParams, options);
logger.info("Successfully connected");
this.startCallStatsTrace();
}

/**
Expand Down Expand Up @@ -431,11 +440,109 @@ export class BandwidthRtc {
.forEach(([, stream]) => stream.mediaStream.getVideoTracks().forEach((track) => (track.enabled = enabled)));
}

/**
* Get a call quality snapshot from the native WebRTC stats, including RTCP data from the remote receiver
* @param previousSnapshot optional earlier snapshot, used to compute inbound/outbound bitrate
*/
async getCallStats(previousSnapshot?: CallStatsSnapshot): Promise<CallStatsSnapshot> {
const snapshot: CallStatsSnapshot = {
packetsReceived: 0,
packetsLost: 0,
bytesReceived: 0,
jitter: 0,
audioLevel: 0,
packetsSent: 0,
bytesSent: 0,
roundTripTime: 0,
codec: "unknown",
inboundBitrate: 0,
outboundBitrate: 0,
timestamp: 0,
remoteFractionLost: 0,
remoteJitter: 0,
rtcpRoundTripTime: 0,
};
// Read the peer connections now; they are replaced on reconnect
const subPc = this.subscribingPeerConnection;
const pubPc = this.publishingPeerConnection;
const [subReport, pubReport] = await Promise.all([
subPc?.getStats().catch((err) => logger.warn("getCallStats: subscribing getStats failed", err)),
pubPc?.getStats().catch((err) => logger.warn("getCallStats: publishing getStats failed", err)),
]);

let codecId: string | undefined;
subReport?.forEach((stat: any) => {
if (stat.type === "inbound-rtp" && stat.kind === "audio") {
snapshot.packetsReceived = stat.packetsReceived ?? 0;
snapshot.packetsLost = stat.packetsLost ?? 0;
snapshot.bytesReceived = stat.bytesReceived ?? 0;
snapshot.jitter = stat.jitter ?? 0;
snapshot.audioLevel = stat.audioLevel ?? 0;
codecId = stat.codecId;
} else if (stat.type === "candidate-pair" && stat.state === "succeeded") {
snapshot.roundTripTime = stat.currentRoundTripTime ?? 0;
}
});
const mimeType = codecId ? subReport?.get(codecId)?.mimeType : undefined;
if (mimeType) {
snapshot.codec = mimeType.replace(/^audio\//, "");
}

pubReport?.forEach((stat: any) => {
if (stat.kind !== "audio") {
return;
}
if (stat.type === "outbound-rtp") {
snapshot.packetsSent = stat.packetsSent ?? 0;
snapshot.bytesSent = stat.bytesSent ?? 0;
} else if (stat.type === "remote-inbound-rtp") {
snapshot.remoteFractionLost = stat.fractionLost ?? 0;
snapshot.remoteJitter = stat.jitter ?? 0;
snapshot.rtcpRoundTripTime = stat.roundTripTime ?? 0;
}
});

snapshot.timestamp = Date.now() / 1000;
const timeDelta = previousSnapshot ? snapshot.timestamp - previousSnapshot.timestamp : 0;
if (previousSnapshot && timeDelta > 0) {
snapshot.inboundBitrate = (Math.max(0, snapshot.bytesReceived - previousSnapshot.bytesReceived) * 8) / timeDelta;
snapshot.outboundBitrate = (Math.max(0, snapshot.bytesSent - previousSnapshot.bytesSent) * 8) / timeDelta;
}
return snapshot;
}

private startCallStatsTrace() {
this.stopCallStatsTrace();
this.callStatsTraceTimer = setInterval(() => this.traceCallStats(), CALL_STATS_TRACE_INTERVAL_MS);
}

private stopCallStatsTrace() {
if (this.callStatsTraceTimer) {
clearInterval(this.callStatsTraceTimer);
this.callStatsTraceTimer = undefined;
}
this.lastTracedCallStats = undefined;
}

private async traceCallStats() {
// Between a teardown and the next reconnect there is nothing to measure
if (!this.publishingPeerConnection && !this.subscribingPeerConnection) {
return;
}
try {
this.lastTracedCallStats = await this.getCallStats(this.lastTracedCallStats);
logger.debug("Call stats", this.lastTracedCallStats);
} catch (err) {
logger.warn("Call stats trace failed", err);
}
}

/**
* Disconnect from the Bandwidth WebRTC platform, and tear down all published streams
*/
disconnect() {
logger.info("Disconnecting");
this.stopCallStatsTrace();
this.cleanupPublishedStreams();
this.publishingPeerConnection?.close();
this.subscribingPeerConnection?.close();
Expand Down
23 changes: 23 additions & 0 deletions src/v1/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,26 @@ export interface ReadyMetadata {
territory: string; // TODO enum
region: string; // TODO enum
}

/** Call quality snapshot parsed from native WebRTC stats. Times are in seconds, bitrates in bits/s. */
export interface CallStatsSnapshot {
packetsReceived: number;
packetsLost: number;
bytesReceived: number;
jitter: number;
audioLevel: number;
packetsSent: number;
bytesSent: number;
roundTripTime: number;
codec: string;
inboundBitrate: number;
outboundBitrate: number;
/** Unix time in seconds */
timestamp: number;
/** RTCP remote-inbound-rtp fractionLost */
remoteFractionLost: number;
/** RTCP remote-inbound-rtp jitter */
remoteJitter: number;
/** RTCP remote-inbound-rtp roundTripTime */
rtcpRoundTripTime: number;
}
Loading