@absolutejs/voice 0.0.22-beta.321 → 0.0.22-beta.323
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/dist/angular/index.js +233 -7
- package/dist/browserMediaRoutes.d.ts +2 -1
- package/dist/client/htmxBootstrap.js +69 -7
- package/dist/client/index.js +233 -7
- package/dist/index.d.ts +2 -0
- package/dist/index.js +816 -444
- package/dist/react/index.js +233 -7
- package/dist/svelte/index.js +233 -7
- package/dist/telephonyMediaRoutes.d.ts +63 -0
- package/dist/testing/index.js +246 -20
- package/dist/types.d.ts +3 -1
- package/dist/vue/index.js +233 -7
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4913,6 +4913,13 @@ Shared stream options:
|
|
|
4913
4913
|
|
|
4914
4914
|
```ts
|
|
4915
4915
|
const browserMedia = {
|
|
4916
|
+
continuity: {
|
|
4917
|
+
maxGapMs: 7000,
|
|
4918
|
+
maxInboundPacketStallMs: 7000,
|
|
4919
|
+
maxOutboundPacketStallMs: 7000,
|
|
4920
|
+
requireInboundAudio: true,
|
|
4921
|
+
requireOutboundAudio: true
|
|
4922
|
+
},
|
|
4916
4923
|
getPeerConnection: () => peerConnection,
|
|
4917
4924
|
maxJitterMs: 30,
|
|
4918
4925
|
maxPacketLossRatio: 0.02,
|
package/dist/angular/index.js
CHANGED
|
@@ -1412,7 +1412,65 @@ var stringStat = (stat, key) => {
|
|
|
1412
1412
|
const value = stat[key];
|
|
1413
1413
|
return typeof value === "string" ? value : undefined;
|
|
1414
1414
|
};
|
|
1415
|
+
var statKey = (stat) => String(stat.id ?? stringStat(stat, "ssrc") ?? numericStat(stat, "ssrc") ?? stringStat(stat, "trackIdentifier") ?? stringStat(stat, "mid") ?? "unknown");
|
|
1415
1416
|
var secondsToMs = (value) => value === undefined ? undefined : value * 1000;
|
|
1417
|
+
var DEFAULT_TELEPHONY_FORMAT = {
|
|
1418
|
+
channels: 1,
|
|
1419
|
+
container: "raw",
|
|
1420
|
+
encoding: "mulaw",
|
|
1421
|
+
sampleRateHz: 8000
|
|
1422
|
+
};
|
|
1423
|
+
var bytesToBase64 = (audio) => {
|
|
1424
|
+
const bytes = audio instanceof ArrayBuffer ? new Uint8Array(audio) : new Uint8Array(audio.buffer, audio.byteOffset, audio.byteLength);
|
|
1425
|
+
return Buffer.from(bytes).toString("base64");
|
|
1426
|
+
};
|
|
1427
|
+
var base64ToBytes = (value) => new Uint8Array(Buffer.from(value, "base64"));
|
|
1428
|
+
var unknownRecord = (value) => value && typeof value === "object" ? value : {};
|
|
1429
|
+
var firstString = (records, keys) => {
|
|
1430
|
+
for (const record of records) {
|
|
1431
|
+
for (const key of keys) {
|
|
1432
|
+
const value = record[key];
|
|
1433
|
+
if (typeof value === "string" && value.length > 0) {
|
|
1434
|
+
return value;
|
|
1435
|
+
}
|
|
1436
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1437
|
+
return String(value);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
return;
|
|
1442
|
+
};
|
|
1443
|
+
var firstNumber = (records, keys) => {
|
|
1444
|
+
for (const record of records) {
|
|
1445
|
+
for (const key of keys) {
|
|
1446
|
+
const value = record[key];
|
|
1447
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1448
|
+
return value;
|
|
1449
|
+
}
|
|
1450
|
+
if (typeof value === "string") {
|
|
1451
|
+
const parsed = Number(value);
|
|
1452
|
+
if (Number.isFinite(parsed)) {
|
|
1453
|
+
return parsed;
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
return;
|
|
1459
|
+
};
|
|
1460
|
+
var telephonyDirection = (track) => {
|
|
1461
|
+
const normalized = track?.toLowerCase();
|
|
1462
|
+
if (!normalized) {
|
|
1463
|
+
return "unknown";
|
|
1464
|
+
}
|
|
1465
|
+
if (normalized.includes("inbound") || normalized.includes("caller") || normalized.includes("in")) {
|
|
1466
|
+
return "inbound";
|
|
1467
|
+
}
|
|
1468
|
+
if (normalized.includes("outbound") || normalized.includes("assistant") || normalized.includes("out")) {
|
|
1469
|
+
return "outbound";
|
|
1470
|
+
}
|
|
1471
|
+
return "unknown";
|
|
1472
|
+
};
|
|
1473
|
+
var telephonyFrameKind = (direction) => direction === "outbound" ? "assistant-audio" : "input-audio";
|
|
1416
1474
|
var normalizeWebRTCStat = (stat) => {
|
|
1417
1475
|
const sample = {};
|
|
1418
1476
|
for (const [key, value] of Object.entries(stat)) {
|
|
@@ -1422,6 +1480,113 @@ var normalizeWebRTCStat = (stat) => {
|
|
|
1422
1480
|
}
|
|
1423
1481
|
return sample;
|
|
1424
1482
|
};
|
|
1483
|
+
var parseTelephonyMediaFrame = (input) => {
|
|
1484
|
+
const envelope = input.envelope;
|
|
1485
|
+
const media = unknownRecord(envelope.media);
|
|
1486
|
+
const payload = firstString([media, envelope], ["payload", "audio", "data"]) ?? firstString([unknownRecord(envelope.message)], ["payload"]);
|
|
1487
|
+
if (!payload) {
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
const carrier = input.carrier ?? firstString([envelope], ["provider"]) ?? "telephony";
|
|
1491
|
+
const streamId = firstString([media, envelope], ["streamSid", "stream_id", "streamId", "streamId", "callSid", "call_id"]);
|
|
1492
|
+
const sequenceNumber = firstString([media, envelope], ["sequenceNumber", "sequence_number", "chunk"]);
|
|
1493
|
+
const track = firstString([media, envelope], ["track", "direction"]);
|
|
1494
|
+
const direction = telephonyDirection(track);
|
|
1495
|
+
const timestamp = firstNumber([media, envelope], ["timestamp", "time", "startedAt"]);
|
|
1496
|
+
return {
|
|
1497
|
+
at: timestamp,
|
|
1498
|
+
audio: base64ToBytes(payload),
|
|
1499
|
+
format: input.format ?? DEFAULT_TELEPHONY_FORMAT,
|
|
1500
|
+
id: [
|
|
1501
|
+
carrier,
|
|
1502
|
+
streamId ?? input.sessionId ?? "stream",
|
|
1503
|
+
sequenceNumber ?? timestamp ?? Date.now()
|
|
1504
|
+
].join(":"),
|
|
1505
|
+
kind: telephonyFrameKind(direction),
|
|
1506
|
+
metadata: {
|
|
1507
|
+
carrier,
|
|
1508
|
+
direction,
|
|
1509
|
+
event: firstString([envelope], ["event", "type"]),
|
|
1510
|
+
sequenceNumber,
|
|
1511
|
+
streamId,
|
|
1512
|
+
track
|
|
1513
|
+
},
|
|
1514
|
+
sessionId: input.sessionId ?? streamId,
|
|
1515
|
+
source: "telephony"
|
|
1516
|
+
};
|
|
1517
|
+
};
|
|
1518
|
+
var serializeTelephonyMediaFrame = (input) => {
|
|
1519
|
+
const carrier = input.carrier ?? input.frame.metadata?.carrier ?? "telephony";
|
|
1520
|
+
const streamId = input.streamId ?? (typeof input.frame.metadata?.streamId === "string" ? input.frame.metadata.streamId : input.frame.sessionId);
|
|
1521
|
+
const sequenceNumber = input.sequenceNumber ?? (typeof input.frame.metadata?.sequenceNumber === "string" || typeof input.frame.metadata?.sequenceNumber === "number" ? input.frame.metadata.sequenceNumber : undefined);
|
|
1522
|
+
const direction = input.frame.kind === "assistant-audio" ? "outbound" : "inbound";
|
|
1523
|
+
const payload = input.frame.audio ? bytesToBase64(input.frame.audio) : "";
|
|
1524
|
+
if (carrier === "twilio") {
|
|
1525
|
+
return {
|
|
1526
|
+
event: "media",
|
|
1527
|
+
sequenceNumber,
|
|
1528
|
+
streamSid: streamId,
|
|
1529
|
+
media: {
|
|
1530
|
+
payload,
|
|
1531
|
+
timestamp: input.frame.at,
|
|
1532
|
+
track: direction
|
|
1533
|
+
}
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
if (carrier === "telnyx") {
|
|
1537
|
+
return {
|
|
1538
|
+
event: "media",
|
|
1539
|
+
stream_id: streamId,
|
|
1540
|
+
sequence_number: sequenceNumber,
|
|
1541
|
+
media: {
|
|
1542
|
+
payload,
|
|
1543
|
+
timestamp: input.frame.at,
|
|
1544
|
+
track: direction
|
|
1545
|
+
}
|
|
1546
|
+
};
|
|
1547
|
+
}
|
|
1548
|
+
if (carrier === "plivo") {
|
|
1549
|
+
return {
|
|
1550
|
+
event: "media",
|
|
1551
|
+
streamId,
|
|
1552
|
+
sequenceNumber,
|
|
1553
|
+
media: {
|
|
1554
|
+
payload,
|
|
1555
|
+
timestamp: input.frame.at,
|
|
1556
|
+
track: direction
|
|
1557
|
+
}
|
|
1558
|
+
};
|
|
1559
|
+
}
|
|
1560
|
+
return {
|
|
1561
|
+
event: "media",
|
|
1562
|
+
provider: carrier,
|
|
1563
|
+
sequenceNumber,
|
|
1564
|
+
streamId,
|
|
1565
|
+
media: {
|
|
1566
|
+
payload,
|
|
1567
|
+
timestamp: input.frame.at,
|
|
1568
|
+
track: direction
|
|
1569
|
+
}
|
|
1570
|
+
};
|
|
1571
|
+
};
|
|
1572
|
+
var createTelephonyMediaSerializer = (input) => {
|
|
1573
|
+
const format = input.format ?? DEFAULT_TELEPHONY_FORMAT;
|
|
1574
|
+
return {
|
|
1575
|
+
carrier: input.carrier,
|
|
1576
|
+
format,
|
|
1577
|
+
parse: (envelope) => parseTelephonyMediaFrame({
|
|
1578
|
+
carrier: input.carrier,
|
|
1579
|
+
envelope,
|
|
1580
|
+
format,
|
|
1581
|
+
sessionId: input.sessionId ?? input.streamId
|
|
1582
|
+
}),
|
|
1583
|
+
serialize: (frame) => serializeTelephonyMediaFrame({
|
|
1584
|
+
carrier: input.carrier,
|
|
1585
|
+
frame,
|
|
1586
|
+
streamId: input.streamId
|
|
1587
|
+
})
|
|
1588
|
+
};
|
|
1589
|
+
};
|
|
1425
1590
|
var buildMediaResamplingPlan = (input) => {
|
|
1426
1591
|
const required = !formatMatches(input.inputFormat, input.outputFormat);
|
|
1427
1592
|
return {
|
|
@@ -1658,12 +1823,64 @@ var collectMediaWebRTCStats = async (input) => {
|
|
|
1658
1823
|
const report = await input.peerConnection.getStats(input.selector ?? null);
|
|
1659
1824
|
return [...report.values()].map(normalizeWebRTCStat);
|
|
1660
1825
|
};
|
|
1661
|
-
var
|
|
1662
|
-
const stats =
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1826
|
+
var buildMediaWebRTCStreamContinuityReport = (input = {}) => {
|
|
1827
|
+
const stats = input.stats ?? [];
|
|
1828
|
+
const previousStats = input.previousStats ?? [];
|
|
1829
|
+
const issues = [];
|
|
1830
|
+
const previousByKey = new Map(previousStats.map((stat) => [statKey(stat), stat]));
|
|
1831
|
+
const audioRtp = stats.filter((stat) => (stat.type === "inbound-rtp" || stat.type === "outbound-rtp") && stringStat(stat, "kind") !== "video" && stringStat(stat, "mediaType") !== "video");
|
|
1832
|
+
const streams = audioRtp.map((stat) => {
|
|
1833
|
+
const direction = stat.type === "outbound-rtp" ? "outbound" : "inbound";
|
|
1834
|
+
const packetsKey = direction === "outbound" ? "packetsSent" : "packetsReceived";
|
|
1835
|
+
const bytesKey = direction === "outbound" ? "bytesSent" : "bytesReceived";
|
|
1836
|
+
const previous = previousByKey.get(statKey(stat));
|
|
1837
|
+
const currentPackets = numericStat(stat, packetsKey);
|
|
1838
|
+
const previousPackets = previous ? numericStat(previous, packetsKey) : undefined;
|
|
1839
|
+
const currentBytes = numericStat(stat, bytesKey);
|
|
1840
|
+
const previousBytes = previous ? numericStat(previous, bytesKey) : undefined;
|
|
1841
|
+
const timeDeltaMs = stat.timestamp !== undefined && previous?.timestamp !== undefined ? stat.timestamp - previous.timestamp : undefined;
|
|
1842
|
+
return {
|
|
1843
|
+
bytesDelta: currentBytes !== undefined && previousBytes !== undefined ? currentBytes - previousBytes : undefined,
|
|
1844
|
+
currentPackets,
|
|
1845
|
+
direction,
|
|
1846
|
+
id: statKey(stat),
|
|
1847
|
+
packetDelta: currentPackets !== undefined && previousPackets !== undefined ? currentPackets - previousPackets : undefined,
|
|
1848
|
+
previousPackets,
|
|
1849
|
+
timeDeltaMs
|
|
1850
|
+
};
|
|
1666
1851
|
});
|
|
1852
|
+
const inbound = streams.filter((stream) => stream.direction === "inbound");
|
|
1853
|
+
const outbound = streams.filter((stream) => stream.direction === "outbound");
|
|
1854
|
+
const maxObservedGapMs = max(streams.map((stream) => stream.timeDeltaMs).filter((value) => value !== undefined));
|
|
1855
|
+
const stalledInboundStreams = inbound.filter((stream) => input.maxInboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxInboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
|
|
1856
|
+
const stalledOutboundStreams = outbound.filter((stream) => input.maxOutboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxOutboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
|
|
1857
|
+
if (input.requireInboundAudio && inbound.length === 0) {
|
|
1858
|
+
pushIssue(issues, "error", "media.webrtc_inbound_audio_missing", "No inbound WebRTC audio RTP stream was observed.");
|
|
1859
|
+
}
|
|
1860
|
+
if (input.requireOutboundAudio && outbound.length === 0) {
|
|
1861
|
+
pushIssue(issues, "error", "media.webrtc_outbound_audio_missing", "No outbound WebRTC audio RTP stream was observed.");
|
|
1862
|
+
}
|
|
1863
|
+
if (input.maxGapMs !== undefined && maxObservedGapMs !== undefined && maxObservedGapMs > input.maxGapMs) {
|
|
1864
|
+
pushIssue(issues, "warning", "media.webrtc_stream_gap", `Observed WebRTC stream sample gap ${String(maxObservedGapMs)}ms above ${String(input.maxGapMs)}ms.`);
|
|
1865
|
+
}
|
|
1866
|
+
if (stalledInboundStreams > 0) {
|
|
1867
|
+
pushIssue(issues, "error", "media.webrtc_inbound_stalled", `${String(stalledInboundStreams)} inbound WebRTC audio stream(s) stopped receiving packets.`);
|
|
1868
|
+
}
|
|
1869
|
+
if (stalledOutboundStreams > 0) {
|
|
1870
|
+
pushIssue(issues, "error", "media.webrtc_outbound_stalled", `${String(stalledOutboundStreams)} outbound WebRTC audio stream(s) stopped sending packets.`);
|
|
1871
|
+
}
|
|
1872
|
+
return {
|
|
1873
|
+
checkedAt: Date.now(),
|
|
1874
|
+
inboundAudioStreams: inbound.length,
|
|
1875
|
+
issues,
|
|
1876
|
+
maxObservedGapMs,
|
|
1877
|
+
outboundAudioStreams: outbound.length,
|
|
1878
|
+
stalledInboundStreams,
|
|
1879
|
+
stalledOutboundStreams,
|
|
1880
|
+
status: issues.some((issue) => issue.severity === "error") ? "fail" : issues.length > 0 ? "warn" : "pass",
|
|
1881
|
+
streams,
|
|
1882
|
+
totalStats: stats.length
|
|
1883
|
+
};
|
|
1667
1884
|
};
|
|
1668
1885
|
var buildMediaPipelineCalibrationReport = (input = {}) => {
|
|
1669
1886
|
const frames = input.frames ?? [];
|
|
@@ -1749,21 +1966,30 @@ var postBrowserMediaReport = async (payload, options) => {
|
|
|
1749
1966
|
};
|
|
1750
1967
|
var createVoiceBrowserMediaReporter = (options) => {
|
|
1751
1968
|
let interval = null;
|
|
1969
|
+
let previousStats = [];
|
|
1752
1970
|
const reportOnce = async () => {
|
|
1753
1971
|
const peerConnection = await resolvePeerConnection(options);
|
|
1754
1972
|
if (!peerConnection) {
|
|
1755
1973
|
return;
|
|
1756
1974
|
}
|
|
1757
|
-
const
|
|
1975
|
+
const stats = await collectMediaWebRTCStats({ peerConnection });
|
|
1976
|
+
const report = buildMediaWebRTCStatsReport({
|
|
1758
1977
|
...options,
|
|
1759
|
-
|
|
1978
|
+
stats
|
|
1979
|
+
});
|
|
1980
|
+
const continuity = options.continuity === false ? undefined : buildMediaWebRTCStreamContinuityReport({
|
|
1981
|
+
...options.continuity,
|
|
1982
|
+
previousStats,
|
|
1983
|
+
stats
|
|
1760
1984
|
});
|
|
1761
1985
|
const payload = {
|
|
1762
1986
|
at: Date.now(),
|
|
1987
|
+
continuity,
|
|
1763
1988
|
report,
|
|
1764
1989
|
scenarioId: options.getScenarioId?.() ?? null,
|
|
1765
1990
|
sessionId: options.getSessionId?.() ?? null
|
|
1766
1991
|
};
|
|
1992
|
+
previousStats = stats;
|
|
1767
1993
|
options.onReport?.(payload);
|
|
1768
1994
|
await postBrowserMediaReport(payload, options);
|
|
1769
1995
|
return payload;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { Elysia } from 'elysia';
|
|
2
|
-
import type { MediaWebRTCStatsReport } from '@absolutejs/media';
|
|
2
|
+
import type { MediaWebRTCStatsReport, MediaWebRTCStreamContinuityReport } from '@absolutejs/media';
|
|
3
3
|
import type { VoiceTraceEventStore } from './trace';
|
|
4
4
|
export type VoiceBrowserMediaStatus = 'empty' | 'fail' | 'pass' | 'warn';
|
|
5
5
|
export type VoiceBrowserMediaSample = {
|
|
6
6
|
at: number;
|
|
7
|
+
continuity?: MediaWebRTCStreamContinuityReport;
|
|
7
8
|
report: MediaWebRTCStatsReport;
|
|
8
9
|
scenarioId?: string;
|
|
9
10
|
sessionId: string;
|
|
@@ -260,6 +260,7 @@ var stringStat = (stat, key) => {
|
|
|
260
260
|
const value = stat[key];
|
|
261
261
|
return typeof value === "string" ? value : undefined;
|
|
262
262
|
};
|
|
263
|
+
var statKey = (stat) => String(stat.id ?? stringStat(stat, "ssrc") ?? numericStat(stat, "ssrc") ?? stringStat(stat, "trackIdentifier") ?? stringStat(stat, "mid") ?? "unknown");
|
|
263
264
|
var secondsToMs = (value) => value === undefined ? undefined : value * 1000;
|
|
264
265
|
var normalizeWebRTCStat = (stat) => {
|
|
265
266
|
const sample = {};
|
|
@@ -334,12 +335,64 @@ var collectMediaWebRTCStats = async (input) => {
|
|
|
334
335
|
const report = await input.peerConnection.getStats(input.selector ?? null);
|
|
335
336
|
return [...report.values()].map(normalizeWebRTCStat);
|
|
336
337
|
};
|
|
337
|
-
var
|
|
338
|
-
const stats =
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
338
|
+
var buildMediaWebRTCStreamContinuityReport = (input = {}) => {
|
|
339
|
+
const stats = input.stats ?? [];
|
|
340
|
+
const previousStats = input.previousStats ?? [];
|
|
341
|
+
const issues = [];
|
|
342
|
+
const previousByKey = new Map(previousStats.map((stat) => [statKey(stat), stat]));
|
|
343
|
+
const audioRtp = stats.filter((stat) => (stat.type === "inbound-rtp" || stat.type === "outbound-rtp") && stringStat(stat, "kind") !== "video" && stringStat(stat, "mediaType") !== "video");
|
|
344
|
+
const streams = audioRtp.map((stat) => {
|
|
345
|
+
const direction = stat.type === "outbound-rtp" ? "outbound" : "inbound";
|
|
346
|
+
const packetsKey = direction === "outbound" ? "packetsSent" : "packetsReceived";
|
|
347
|
+
const bytesKey = direction === "outbound" ? "bytesSent" : "bytesReceived";
|
|
348
|
+
const previous = previousByKey.get(statKey(stat));
|
|
349
|
+
const currentPackets = numericStat(stat, packetsKey);
|
|
350
|
+
const previousPackets = previous ? numericStat(previous, packetsKey) : undefined;
|
|
351
|
+
const currentBytes = numericStat(stat, bytesKey);
|
|
352
|
+
const previousBytes = previous ? numericStat(previous, bytesKey) : undefined;
|
|
353
|
+
const timeDeltaMs = stat.timestamp !== undefined && previous?.timestamp !== undefined ? stat.timestamp - previous.timestamp : undefined;
|
|
354
|
+
return {
|
|
355
|
+
bytesDelta: currentBytes !== undefined && previousBytes !== undefined ? currentBytes - previousBytes : undefined,
|
|
356
|
+
currentPackets,
|
|
357
|
+
direction,
|
|
358
|
+
id: statKey(stat),
|
|
359
|
+
packetDelta: currentPackets !== undefined && previousPackets !== undefined ? currentPackets - previousPackets : undefined,
|
|
360
|
+
previousPackets,
|
|
361
|
+
timeDeltaMs
|
|
362
|
+
};
|
|
342
363
|
});
|
|
364
|
+
const inbound = streams.filter((stream) => stream.direction === "inbound");
|
|
365
|
+
const outbound = streams.filter((stream) => stream.direction === "outbound");
|
|
366
|
+
const maxObservedGapMs = max(streams.map((stream) => stream.timeDeltaMs).filter((value) => value !== undefined));
|
|
367
|
+
const stalledInboundStreams = inbound.filter((stream) => input.maxInboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxInboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
|
|
368
|
+
const stalledOutboundStreams = outbound.filter((stream) => input.maxOutboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxOutboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
|
|
369
|
+
if (input.requireInboundAudio && inbound.length === 0) {
|
|
370
|
+
pushIssue(issues, "error", "media.webrtc_inbound_audio_missing", "No inbound WebRTC audio RTP stream was observed.");
|
|
371
|
+
}
|
|
372
|
+
if (input.requireOutboundAudio && outbound.length === 0) {
|
|
373
|
+
pushIssue(issues, "error", "media.webrtc_outbound_audio_missing", "No outbound WebRTC audio RTP stream was observed.");
|
|
374
|
+
}
|
|
375
|
+
if (input.maxGapMs !== undefined && maxObservedGapMs !== undefined && maxObservedGapMs > input.maxGapMs) {
|
|
376
|
+
pushIssue(issues, "warning", "media.webrtc_stream_gap", `Observed WebRTC stream sample gap ${String(maxObservedGapMs)}ms above ${String(input.maxGapMs)}ms.`);
|
|
377
|
+
}
|
|
378
|
+
if (stalledInboundStreams > 0) {
|
|
379
|
+
pushIssue(issues, "error", "media.webrtc_inbound_stalled", `${String(stalledInboundStreams)} inbound WebRTC audio stream(s) stopped receiving packets.`);
|
|
380
|
+
}
|
|
381
|
+
if (stalledOutboundStreams > 0) {
|
|
382
|
+
pushIssue(issues, "error", "media.webrtc_outbound_stalled", `${String(stalledOutboundStreams)} outbound WebRTC audio stream(s) stopped sending packets.`);
|
|
383
|
+
}
|
|
384
|
+
return {
|
|
385
|
+
checkedAt: Date.now(),
|
|
386
|
+
inboundAudioStreams: inbound.length,
|
|
387
|
+
issues,
|
|
388
|
+
maxObservedGapMs,
|
|
389
|
+
outboundAudioStreams: outbound.length,
|
|
390
|
+
stalledInboundStreams,
|
|
391
|
+
stalledOutboundStreams,
|
|
392
|
+
status: issues.some((issue) => issue.severity === "error") ? "fail" : issues.length > 0 ? "warn" : "pass",
|
|
393
|
+
streams,
|
|
394
|
+
totalStats: stats.length
|
|
395
|
+
};
|
|
343
396
|
};
|
|
344
397
|
|
|
345
398
|
// src/client/browserMedia.ts
|
|
@@ -362,21 +415,30 @@ var postBrowserMediaReport = async (payload, options) => {
|
|
|
362
415
|
};
|
|
363
416
|
var createVoiceBrowserMediaReporter = (options) => {
|
|
364
417
|
let interval = null;
|
|
418
|
+
let previousStats = [];
|
|
365
419
|
const reportOnce = async () => {
|
|
366
420
|
const peerConnection = await resolvePeerConnection(options);
|
|
367
421
|
if (!peerConnection) {
|
|
368
422
|
return;
|
|
369
423
|
}
|
|
370
|
-
const
|
|
424
|
+
const stats = await collectMediaWebRTCStats({ peerConnection });
|
|
425
|
+
const report = buildMediaWebRTCStatsReport({
|
|
371
426
|
...options,
|
|
372
|
-
|
|
427
|
+
stats
|
|
428
|
+
});
|
|
429
|
+
const continuity = options.continuity === false ? undefined : buildMediaWebRTCStreamContinuityReport({
|
|
430
|
+
...options.continuity,
|
|
431
|
+
previousStats,
|
|
432
|
+
stats
|
|
373
433
|
});
|
|
374
434
|
const payload = {
|
|
375
435
|
at: Date.now(),
|
|
436
|
+
continuity,
|
|
376
437
|
report,
|
|
377
438
|
scenarioId: options.getScenarioId?.() ?? null,
|
|
378
439
|
sessionId: options.getSessionId?.() ?? null
|
|
379
440
|
};
|
|
441
|
+
previousStats = stats;
|
|
380
442
|
options.onReport?.(payload);
|
|
381
443
|
await postBrowserMediaReport(payload, options);
|
|
382
444
|
return payload;
|