@absolutejs/voice 0.0.22-beta.320 → 0.0.22-beta.322
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 +129 -0
- package/dist/angular/index.js +69 -7
- package/dist/browserMediaRoutes.d.ts +2 -1
- package/dist/client/htmxBootstrap.js +69 -7
- package/dist/client/index.js +69 -7
- package/dist/index.js +88 -9
- package/dist/react/index.js +69 -7
- package/dist/svelte/index.js +69 -7
- package/dist/testing/index.js +69 -7
- package/dist/types.d.ts +3 -1
- package/dist/vue/index.js +69 -7
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4882,3 +4882,132 @@ Browser and framework helpers sit on top of the same connection core:
|
|
|
4882
4882
|
- `VoiceStreamService` in `@absolutejs/voice/angular`
|
|
4883
4883
|
|
|
4884
4884
|
For plain HTML or HTMX flows, use `@absolutejs/voice/client` directly.
|
|
4885
|
+
|
|
4886
|
+
### Browser Media Proof
|
|
4887
|
+
|
|
4888
|
+
If your app owns a browser `RTCPeerConnection`, pass it through `browserMedia` so AbsoluteJS can persist real `RTCPeerConnection.getStats()` evidence and feed production readiness. The default WebSocket microphone flow does not require this; this is for WebRTC voice surfaces where browser transport quality matters.
|
|
4889
|
+
|
|
4890
|
+
Server route setup:
|
|
4891
|
+
|
|
4892
|
+
```ts
|
|
4893
|
+
import {
|
|
4894
|
+
createVoiceBrowserMediaRoutes,
|
|
4895
|
+
createVoiceProductionReadinessRoutes,
|
|
4896
|
+
getLatestVoiceBrowserMediaReport
|
|
4897
|
+
} from '@absolutejs/voice';
|
|
4898
|
+
|
|
4899
|
+
app
|
|
4900
|
+
.use(createVoiceBrowserMediaRoutes({ store: runtime.traces }))
|
|
4901
|
+
.use(
|
|
4902
|
+
createVoiceProductionReadinessRoutes({
|
|
4903
|
+
browserMedia: () =>
|
|
4904
|
+
getLatestVoiceBrowserMediaReport({ store: runtime.traces }),
|
|
4905
|
+
links: {
|
|
4906
|
+
browserMedia: '/voice/browser-media'
|
|
4907
|
+
}
|
|
4908
|
+
})
|
|
4909
|
+
);
|
|
4910
|
+
```
|
|
4911
|
+
|
|
4912
|
+
Shared stream options:
|
|
4913
|
+
|
|
4914
|
+
```ts
|
|
4915
|
+
const browserMedia = {
|
|
4916
|
+
continuity: {
|
|
4917
|
+
maxGapMs: 7000,
|
|
4918
|
+
maxInboundPacketStallMs: 7000,
|
|
4919
|
+
maxOutboundPacketStallMs: 7000,
|
|
4920
|
+
requireInboundAudio: true,
|
|
4921
|
+
requireOutboundAudio: true
|
|
4922
|
+
},
|
|
4923
|
+
getPeerConnection: () => peerConnection,
|
|
4924
|
+
maxJitterMs: 30,
|
|
4925
|
+
maxPacketLossRatio: 0.02,
|
|
4926
|
+
maxRoundTripTimeMs: 250,
|
|
4927
|
+
requireConnectedCandidatePair: true,
|
|
4928
|
+
requireLiveAudioTrack: true
|
|
4929
|
+
};
|
|
4930
|
+
```
|
|
4931
|
+
|
|
4932
|
+
React:
|
|
4933
|
+
|
|
4934
|
+
```tsx
|
|
4935
|
+
import { useRef } from 'react';
|
|
4936
|
+
import { useVoiceStream } from '@absolutejs/voice/react';
|
|
4937
|
+
|
|
4938
|
+
export function WebRTCVoice() {
|
|
4939
|
+
const peerConnection = useRef<RTCPeerConnection | null>(null);
|
|
4940
|
+
const voice = useVoiceStream('/voice/support', {
|
|
4941
|
+
browserMedia: {
|
|
4942
|
+
...browserMedia,
|
|
4943
|
+
getPeerConnection: () => peerConnection.current
|
|
4944
|
+
}
|
|
4945
|
+
});
|
|
4946
|
+
|
|
4947
|
+
return <button onClick={() => voice.close()}>End call</button>;
|
|
4948
|
+
}
|
|
4949
|
+
```
|
|
4950
|
+
|
|
4951
|
+
Vue:
|
|
4952
|
+
|
|
4953
|
+
```ts
|
|
4954
|
+
import { shallowRef } from 'vue';
|
|
4955
|
+
import { useVoiceStream } from '@absolutejs/voice/vue';
|
|
4956
|
+
|
|
4957
|
+
const peerConnection = shallowRef<RTCPeerConnection | null>(null);
|
|
4958
|
+
const voice = useVoiceStream('/voice/support', {
|
|
4959
|
+
browserMedia: {
|
|
4960
|
+
...browserMedia,
|
|
4961
|
+
getPeerConnection: () => peerConnection.value
|
|
4962
|
+
}
|
|
4963
|
+
});
|
|
4964
|
+
```
|
|
4965
|
+
|
|
4966
|
+
Svelte:
|
|
4967
|
+
|
|
4968
|
+
```ts
|
|
4969
|
+
import { createVoiceStream } from '@absolutejs/voice/svelte';
|
|
4970
|
+
|
|
4971
|
+
let peerConnection: RTCPeerConnection | null = null;
|
|
4972
|
+
const voice = createVoiceStream('/voice/support', {
|
|
4973
|
+
browserMedia: {
|
|
4974
|
+
...browserMedia,
|
|
4975
|
+
getPeerConnection: () => peerConnection
|
|
4976
|
+
}
|
|
4977
|
+
});
|
|
4978
|
+
```
|
|
4979
|
+
|
|
4980
|
+
Angular:
|
|
4981
|
+
|
|
4982
|
+
```ts
|
|
4983
|
+
import { Component, inject } from '@angular/core';
|
|
4984
|
+
import { VoiceStreamService } from '@absolutejs/voice/angular';
|
|
4985
|
+
|
|
4986
|
+
@Component({
|
|
4987
|
+
selector: 'app-webrtc-voice',
|
|
4988
|
+
template: `<button type="button" (click)="stream.close()">End call</button>`
|
|
4989
|
+
})
|
|
4990
|
+
export class WebRTCVoiceComponent {
|
|
4991
|
+
private readonly voice = inject(VoiceStreamService);
|
|
4992
|
+
private peerConnection: RTCPeerConnection | null = null;
|
|
4993
|
+
|
|
4994
|
+
readonly stream = this.voice.connect('/voice/support', {
|
|
4995
|
+
browserMedia: {
|
|
4996
|
+
...browserMedia,
|
|
4997
|
+
getPeerConnection: () => this.peerConnection
|
|
4998
|
+
}
|
|
4999
|
+
});
|
|
5000
|
+
}
|
|
5001
|
+
```
|
|
5002
|
+
|
|
5003
|
+
HTMX/plain browser:
|
|
5004
|
+
|
|
5005
|
+
```ts
|
|
5006
|
+
import { createVoiceController } from '@absolutejs/voice/client';
|
|
5007
|
+
|
|
5008
|
+
const voice = createVoiceController('/voice/support', {
|
|
5009
|
+
browserMedia
|
|
5010
|
+
});
|
|
5011
|
+
|
|
5012
|
+
voice.bindHTMX({ element: '#voice-htmx-sync' });
|
|
5013
|
+
```
|
package/dist/angular/index.js
CHANGED
|
@@ -1412,6 +1412,7 @@ 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;
|
|
1416
1417
|
var normalizeWebRTCStat = (stat) => {
|
|
1417
1418
|
const sample = {};
|
|
@@ -1658,12 +1659,64 @@ var collectMediaWebRTCStats = async (input) => {
|
|
|
1658
1659
|
const report = await input.peerConnection.getStats(input.selector ?? null);
|
|
1659
1660
|
return [...report.values()].map(normalizeWebRTCStat);
|
|
1660
1661
|
};
|
|
1661
|
-
var
|
|
1662
|
-
const stats =
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1662
|
+
var buildMediaWebRTCStreamContinuityReport = (input = {}) => {
|
|
1663
|
+
const stats = input.stats ?? [];
|
|
1664
|
+
const previousStats = input.previousStats ?? [];
|
|
1665
|
+
const issues = [];
|
|
1666
|
+
const previousByKey = new Map(previousStats.map((stat) => [statKey(stat), stat]));
|
|
1667
|
+
const audioRtp = stats.filter((stat) => (stat.type === "inbound-rtp" || stat.type === "outbound-rtp") && stringStat(stat, "kind") !== "video" && stringStat(stat, "mediaType") !== "video");
|
|
1668
|
+
const streams = audioRtp.map((stat) => {
|
|
1669
|
+
const direction = stat.type === "outbound-rtp" ? "outbound" : "inbound";
|
|
1670
|
+
const packetsKey = direction === "outbound" ? "packetsSent" : "packetsReceived";
|
|
1671
|
+
const bytesKey = direction === "outbound" ? "bytesSent" : "bytesReceived";
|
|
1672
|
+
const previous = previousByKey.get(statKey(stat));
|
|
1673
|
+
const currentPackets = numericStat(stat, packetsKey);
|
|
1674
|
+
const previousPackets = previous ? numericStat(previous, packetsKey) : undefined;
|
|
1675
|
+
const currentBytes = numericStat(stat, bytesKey);
|
|
1676
|
+
const previousBytes = previous ? numericStat(previous, bytesKey) : undefined;
|
|
1677
|
+
const timeDeltaMs = stat.timestamp !== undefined && previous?.timestamp !== undefined ? stat.timestamp - previous.timestamp : undefined;
|
|
1678
|
+
return {
|
|
1679
|
+
bytesDelta: currentBytes !== undefined && previousBytes !== undefined ? currentBytes - previousBytes : undefined,
|
|
1680
|
+
currentPackets,
|
|
1681
|
+
direction,
|
|
1682
|
+
id: statKey(stat),
|
|
1683
|
+
packetDelta: currentPackets !== undefined && previousPackets !== undefined ? currentPackets - previousPackets : undefined,
|
|
1684
|
+
previousPackets,
|
|
1685
|
+
timeDeltaMs
|
|
1686
|
+
};
|
|
1666
1687
|
});
|
|
1688
|
+
const inbound = streams.filter((stream) => stream.direction === "inbound");
|
|
1689
|
+
const outbound = streams.filter((stream) => stream.direction === "outbound");
|
|
1690
|
+
const maxObservedGapMs = max(streams.map((stream) => stream.timeDeltaMs).filter((value) => value !== undefined));
|
|
1691
|
+
const stalledInboundStreams = inbound.filter((stream) => input.maxInboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxInboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
|
|
1692
|
+
const stalledOutboundStreams = outbound.filter((stream) => input.maxOutboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxOutboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
|
|
1693
|
+
if (input.requireInboundAudio && inbound.length === 0) {
|
|
1694
|
+
pushIssue(issues, "error", "media.webrtc_inbound_audio_missing", "No inbound WebRTC audio RTP stream was observed.");
|
|
1695
|
+
}
|
|
1696
|
+
if (input.requireOutboundAudio && outbound.length === 0) {
|
|
1697
|
+
pushIssue(issues, "error", "media.webrtc_outbound_audio_missing", "No outbound WebRTC audio RTP stream was observed.");
|
|
1698
|
+
}
|
|
1699
|
+
if (input.maxGapMs !== undefined && maxObservedGapMs !== undefined && maxObservedGapMs > input.maxGapMs) {
|
|
1700
|
+
pushIssue(issues, "warning", "media.webrtc_stream_gap", `Observed WebRTC stream sample gap ${String(maxObservedGapMs)}ms above ${String(input.maxGapMs)}ms.`);
|
|
1701
|
+
}
|
|
1702
|
+
if (stalledInboundStreams > 0) {
|
|
1703
|
+
pushIssue(issues, "error", "media.webrtc_inbound_stalled", `${String(stalledInboundStreams)} inbound WebRTC audio stream(s) stopped receiving packets.`);
|
|
1704
|
+
}
|
|
1705
|
+
if (stalledOutboundStreams > 0) {
|
|
1706
|
+
pushIssue(issues, "error", "media.webrtc_outbound_stalled", `${String(stalledOutboundStreams)} outbound WebRTC audio stream(s) stopped sending packets.`);
|
|
1707
|
+
}
|
|
1708
|
+
return {
|
|
1709
|
+
checkedAt: Date.now(),
|
|
1710
|
+
inboundAudioStreams: inbound.length,
|
|
1711
|
+
issues,
|
|
1712
|
+
maxObservedGapMs,
|
|
1713
|
+
outboundAudioStreams: outbound.length,
|
|
1714
|
+
stalledInboundStreams,
|
|
1715
|
+
stalledOutboundStreams,
|
|
1716
|
+
status: issues.some((issue) => issue.severity === "error") ? "fail" : issues.length > 0 ? "warn" : "pass",
|
|
1717
|
+
streams,
|
|
1718
|
+
totalStats: stats.length
|
|
1719
|
+
};
|
|
1667
1720
|
};
|
|
1668
1721
|
var buildMediaPipelineCalibrationReport = (input = {}) => {
|
|
1669
1722
|
const frames = input.frames ?? [];
|
|
@@ -1749,21 +1802,30 @@ var postBrowserMediaReport = async (payload, options) => {
|
|
|
1749
1802
|
};
|
|
1750
1803
|
var createVoiceBrowserMediaReporter = (options) => {
|
|
1751
1804
|
let interval = null;
|
|
1805
|
+
let previousStats = [];
|
|
1752
1806
|
const reportOnce = async () => {
|
|
1753
1807
|
const peerConnection = await resolvePeerConnection(options);
|
|
1754
1808
|
if (!peerConnection) {
|
|
1755
1809
|
return;
|
|
1756
1810
|
}
|
|
1757
|
-
const
|
|
1811
|
+
const stats = await collectMediaWebRTCStats({ peerConnection });
|
|
1812
|
+
const report = buildMediaWebRTCStatsReport({
|
|
1758
1813
|
...options,
|
|
1759
|
-
|
|
1814
|
+
stats
|
|
1815
|
+
});
|
|
1816
|
+
const continuity = options.continuity === false ? undefined : buildMediaWebRTCStreamContinuityReport({
|
|
1817
|
+
...options.continuity,
|
|
1818
|
+
previousStats,
|
|
1819
|
+
stats
|
|
1760
1820
|
});
|
|
1761
1821
|
const payload = {
|
|
1762
1822
|
at: Date.now(),
|
|
1823
|
+
continuity,
|
|
1763
1824
|
report,
|
|
1764
1825
|
scenarioId: options.getScenarioId?.() ?? null,
|
|
1765
1826
|
sessionId: options.getSessionId?.() ?? null
|
|
1766
1827
|
};
|
|
1828
|
+
previousStats = stats;
|
|
1767
1829
|
options.onReport?.(payload);
|
|
1768
1830
|
await postBrowserMediaReport(payload, options);
|
|
1769
1831
|
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;
|
package/dist/client/index.js
CHANGED
|
@@ -805,6 +805,7 @@ var stringStat = (stat, key) => {
|
|
|
805
805
|
const value = stat[key];
|
|
806
806
|
return typeof value === "string" ? value : undefined;
|
|
807
807
|
};
|
|
808
|
+
var statKey = (stat) => String(stat.id ?? stringStat(stat, "ssrc") ?? numericStat(stat, "ssrc") ?? stringStat(stat, "trackIdentifier") ?? stringStat(stat, "mid") ?? "unknown");
|
|
808
809
|
var secondsToMs = (value) => value === undefined ? undefined : value * 1000;
|
|
809
810
|
var normalizeWebRTCStat = (stat) => {
|
|
810
811
|
const sample = {};
|
|
@@ -1051,12 +1052,64 @@ var collectMediaWebRTCStats = async (input) => {
|
|
|
1051
1052
|
const report = await input.peerConnection.getStats(input.selector ?? null);
|
|
1052
1053
|
return [...report.values()].map(normalizeWebRTCStat);
|
|
1053
1054
|
};
|
|
1054
|
-
var
|
|
1055
|
-
const stats =
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1055
|
+
var buildMediaWebRTCStreamContinuityReport = (input = {}) => {
|
|
1056
|
+
const stats = input.stats ?? [];
|
|
1057
|
+
const previousStats = input.previousStats ?? [];
|
|
1058
|
+
const issues = [];
|
|
1059
|
+
const previousByKey = new Map(previousStats.map((stat) => [statKey(stat), stat]));
|
|
1060
|
+
const audioRtp = stats.filter((stat) => (stat.type === "inbound-rtp" || stat.type === "outbound-rtp") && stringStat(stat, "kind") !== "video" && stringStat(stat, "mediaType") !== "video");
|
|
1061
|
+
const streams = audioRtp.map((stat) => {
|
|
1062
|
+
const direction = stat.type === "outbound-rtp" ? "outbound" : "inbound";
|
|
1063
|
+
const packetsKey = direction === "outbound" ? "packetsSent" : "packetsReceived";
|
|
1064
|
+
const bytesKey = direction === "outbound" ? "bytesSent" : "bytesReceived";
|
|
1065
|
+
const previous = previousByKey.get(statKey(stat));
|
|
1066
|
+
const currentPackets = numericStat(stat, packetsKey);
|
|
1067
|
+
const previousPackets = previous ? numericStat(previous, packetsKey) : undefined;
|
|
1068
|
+
const currentBytes = numericStat(stat, bytesKey);
|
|
1069
|
+
const previousBytes = previous ? numericStat(previous, bytesKey) : undefined;
|
|
1070
|
+
const timeDeltaMs = stat.timestamp !== undefined && previous?.timestamp !== undefined ? stat.timestamp - previous.timestamp : undefined;
|
|
1071
|
+
return {
|
|
1072
|
+
bytesDelta: currentBytes !== undefined && previousBytes !== undefined ? currentBytes - previousBytes : undefined,
|
|
1073
|
+
currentPackets,
|
|
1074
|
+
direction,
|
|
1075
|
+
id: statKey(stat),
|
|
1076
|
+
packetDelta: currentPackets !== undefined && previousPackets !== undefined ? currentPackets - previousPackets : undefined,
|
|
1077
|
+
previousPackets,
|
|
1078
|
+
timeDeltaMs
|
|
1079
|
+
};
|
|
1059
1080
|
});
|
|
1081
|
+
const inbound = streams.filter((stream) => stream.direction === "inbound");
|
|
1082
|
+
const outbound = streams.filter((stream) => stream.direction === "outbound");
|
|
1083
|
+
const maxObservedGapMs = max(streams.map((stream) => stream.timeDeltaMs).filter((value) => value !== undefined));
|
|
1084
|
+
const stalledInboundStreams = inbound.filter((stream) => input.maxInboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxInboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
|
|
1085
|
+
const stalledOutboundStreams = outbound.filter((stream) => input.maxOutboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxOutboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
|
|
1086
|
+
if (input.requireInboundAudio && inbound.length === 0) {
|
|
1087
|
+
pushIssue(issues, "error", "media.webrtc_inbound_audio_missing", "No inbound WebRTC audio RTP stream was observed.");
|
|
1088
|
+
}
|
|
1089
|
+
if (input.requireOutboundAudio && outbound.length === 0) {
|
|
1090
|
+
pushIssue(issues, "error", "media.webrtc_outbound_audio_missing", "No outbound WebRTC audio RTP stream was observed.");
|
|
1091
|
+
}
|
|
1092
|
+
if (input.maxGapMs !== undefined && maxObservedGapMs !== undefined && maxObservedGapMs > input.maxGapMs) {
|
|
1093
|
+
pushIssue(issues, "warning", "media.webrtc_stream_gap", `Observed WebRTC stream sample gap ${String(maxObservedGapMs)}ms above ${String(input.maxGapMs)}ms.`);
|
|
1094
|
+
}
|
|
1095
|
+
if (stalledInboundStreams > 0) {
|
|
1096
|
+
pushIssue(issues, "error", "media.webrtc_inbound_stalled", `${String(stalledInboundStreams)} inbound WebRTC audio stream(s) stopped receiving packets.`);
|
|
1097
|
+
}
|
|
1098
|
+
if (stalledOutboundStreams > 0) {
|
|
1099
|
+
pushIssue(issues, "error", "media.webrtc_outbound_stalled", `${String(stalledOutboundStreams)} outbound WebRTC audio stream(s) stopped sending packets.`);
|
|
1100
|
+
}
|
|
1101
|
+
return {
|
|
1102
|
+
checkedAt: Date.now(),
|
|
1103
|
+
inboundAudioStreams: inbound.length,
|
|
1104
|
+
issues,
|
|
1105
|
+
maxObservedGapMs,
|
|
1106
|
+
outboundAudioStreams: outbound.length,
|
|
1107
|
+
stalledInboundStreams,
|
|
1108
|
+
stalledOutboundStreams,
|
|
1109
|
+
status: issues.some((issue) => issue.severity === "error") ? "fail" : issues.length > 0 ? "warn" : "pass",
|
|
1110
|
+
streams,
|
|
1111
|
+
totalStats: stats.length
|
|
1112
|
+
};
|
|
1060
1113
|
};
|
|
1061
1114
|
var buildMediaPipelineCalibrationReport = (input = {}) => {
|
|
1062
1115
|
const frames = input.frames ?? [];
|
|
@@ -1142,21 +1195,30 @@ var postBrowserMediaReport = async (payload, options) => {
|
|
|
1142
1195
|
};
|
|
1143
1196
|
var createVoiceBrowserMediaReporter = (options) => {
|
|
1144
1197
|
let interval = null;
|
|
1198
|
+
let previousStats = [];
|
|
1145
1199
|
const reportOnce = async () => {
|
|
1146
1200
|
const peerConnection = await resolvePeerConnection(options);
|
|
1147
1201
|
if (!peerConnection) {
|
|
1148
1202
|
return;
|
|
1149
1203
|
}
|
|
1150
|
-
const
|
|
1204
|
+
const stats = await collectMediaWebRTCStats({ peerConnection });
|
|
1205
|
+
const report = buildMediaWebRTCStatsReport({
|
|
1151
1206
|
...options,
|
|
1152
|
-
|
|
1207
|
+
stats
|
|
1208
|
+
});
|
|
1209
|
+
const continuity = options.continuity === false ? undefined : buildMediaWebRTCStreamContinuityReport({
|
|
1210
|
+
...options.continuity,
|
|
1211
|
+
previousStats,
|
|
1212
|
+
stats
|
|
1153
1213
|
});
|
|
1154
1214
|
const payload = {
|
|
1155
1215
|
at: Date.now(),
|
|
1216
|
+
continuity,
|
|
1156
1217
|
report,
|
|
1157
1218
|
scenarioId: options.getScenarioId?.() ?? null,
|
|
1158
1219
|
sessionId: options.getSessionId?.() ?? null
|
|
1159
1220
|
};
|
|
1221
|
+
previousStats = stats;
|
|
1160
1222
|
options.onReport?.(payload);
|
|
1161
1223
|
await postBrowserMediaReport(payload, options);
|
|
1162
1224
|
return payload;
|
package/dist/index.js
CHANGED
|
@@ -11637,6 +11637,7 @@ var stringStat = (stat, key) => {
|
|
|
11637
11637
|
const value = stat[key];
|
|
11638
11638
|
return typeof value === "string" ? value : undefined;
|
|
11639
11639
|
};
|
|
11640
|
+
var statKey = (stat) => String(stat.id ?? stringStat(stat, "ssrc") ?? numericStat(stat, "ssrc") ?? stringStat(stat, "trackIdentifier") ?? stringStat(stat, "mid") ?? "unknown");
|
|
11640
11641
|
var secondsToMs = (value) => value === undefined ? undefined : value * 1000;
|
|
11641
11642
|
var normalizeWebRTCStat = (stat) => {
|
|
11642
11643
|
const sample = {};
|
|
@@ -11883,12 +11884,64 @@ var collectMediaWebRTCStats = async (input) => {
|
|
|
11883
11884
|
const report = await input.peerConnection.getStats(input.selector ?? null);
|
|
11884
11885
|
return [...report.values()].map(normalizeWebRTCStat);
|
|
11885
11886
|
};
|
|
11886
|
-
var
|
|
11887
|
-
const stats =
|
|
11888
|
-
|
|
11889
|
-
|
|
11890
|
-
|
|
11887
|
+
var buildMediaWebRTCStreamContinuityReport = (input = {}) => {
|
|
11888
|
+
const stats = input.stats ?? [];
|
|
11889
|
+
const previousStats = input.previousStats ?? [];
|
|
11890
|
+
const issues = [];
|
|
11891
|
+
const previousByKey = new Map(previousStats.map((stat) => [statKey(stat), stat]));
|
|
11892
|
+
const audioRtp = stats.filter((stat) => (stat.type === "inbound-rtp" || stat.type === "outbound-rtp") && stringStat(stat, "kind") !== "video" && stringStat(stat, "mediaType") !== "video");
|
|
11893
|
+
const streams = audioRtp.map((stat) => {
|
|
11894
|
+
const direction = stat.type === "outbound-rtp" ? "outbound" : "inbound";
|
|
11895
|
+
const packetsKey = direction === "outbound" ? "packetsSent" : "packetsReceived";
|
|
11896
|
+
const bytesKey = direction === "outbound" ? "bytesSent" : "bytesReceived";
|
|
11897
|
+
const previous = previousByKey.get(statKey(stat));
|
|
11898
|
+
const currentPackets = numericStat(stat, packetsKey);
|
|
11899
|
+
const previousPackets = previous ? numericStat(previous, packetsKey) : undefined;
|
|
11900
|
+
const currentBytes = numericStat(stat, bytesKey);
|
|
11901
|
+
const previousBytes = previous ? numericStat(previous, bytesKey) : undefined;
|
|
11902
|
+
const timeDeltaMs = stat.timestamp !== undefined && previous?.timestamp !== undefined ? stat.timestamp - previous.timestamp : undefined;
|
|
11903
|
+
return {
|
|
11904
|
+
bytesDelta: currentBytes !== undefined && previousBytes !== undefined ? currentBytes - previousBytes : undefined,
|
|
11905
|
+
currentPackets,
|
|
11906
|
+
direction,
|
|
11907
|
+
id: statKey(stat),
|
|
11908
|
+
packetDelta: currentPackets !== undefined && previousPackets !== undefined ? currentPackets - previousPackets : undefined,
|
|
11909
|
+
previousPackets,
|
|
11910
|
+
timeDeltaMs
|
|
11911
|
+
};
|
|
11891
11912
|
});
|
|
11913
|
+
const inbound = streams.filter((stream) => stream.direction === "inbound");
|
|
11914
|
+
const outbound = streams.filter((stream) => stream.direction === "outbound");
|
|
11915
|
+
const maxObservedGapMs = max(streams.map((stream) => stream.timeDeltaMs).filter((value) => value !== undefined));
|
|
11916
|
+
const stalledInboundStreams = inbound.filter((stream) => input.maxInboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxInboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
|
|
11917
|
+
const stalledOutboundStreams = outbound.filter((stream) => input.maxOutboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxOutboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
|
|
11918
|
+
if (input.requireInboundAudio && inbound.length === 0) {
|
|
11919
|
+
pushIssue(issues, "error", "media.webrtc_inbound_audio_missing", "No inbound WebRTC audio RTP stream was observed.");
|
|
11920
|
+
}
|
|
11921
|
+
if (input.requireOutboundAudio && outbound.length === 0) {
|
|
11922
|
+
pushIssue(issues, "error", "media.webrtc_outbound_audio_missing", "No outbound WebRTC audio RTP stream was observed.");
|
|
11923
|
+
}
|
|
11924
|
+
if (input.maxGapMs !== undefined && maxObservedGapMs !== undefined && maxObservedGapMs > input.maxGapMs) {
|
|
11925
|
+
pushIssue(issues, "warning", "media.webrtc_stream_gap", `Observed WebRTC stream sample gap ${String(maxObservedGapMs)}ms above ${String(input.maxGapMs)}ms.`);
|
|
11926
|
+
}
|
|
11927
|
+
if (stalledInboundStreams > 0) {
|
|
11928
|
+
pushIssue(issues, "error", "media.webrtc_inbound_stalled", `${String(stalledInboundStreams)} inbound WebRTC audio stream(s) stopped receiving packets.`);
|
|
11929
|
+
}
|
|
11930
|
+
if (stalledOutboundStreams > 0) {
|
|
11931
|
+
pushIssue(issues, "error", "media.webrtc_outbound_stalled", `${String(stalledOutboundStreams)} outbound WebRTC audio stream(s) stopped sending packets.`);
|
|
11932
|
+
}
|
|
11933
|
+
return {
|
|
11934
|
+
checkedAt: Date.now(),
|
|
11935
|
+
inboundAudioStreams: inbound.length,
|
|
11936
|
+
issues,
|
|
11937
|
+
maxObservedGapMs,
|
|
11938
|
+
outboundAudioStreams: outbound.length,
|
|
11939
|
+
stalledInboundStreams,
|
|
11940
|
+
stalledOutboundStreams,
|
|
11941
|
+
status: issues.some((issue) => issue.severity === "error") ? "fail" : issues.length > 0 ? "warn" : "pass",
|
|
11942
|
+
streams,
|
|
11943
|
+
totalStats: stats.length
|
|
11944
|
+
};
|
|
11892
11945
|
};
|
|
11893
11946
|
var buildMediaPipelineCalibrationReport = (input = {}) => {
|
|
11894
11947
|
const frames = input.frames ?? [];
|
|
@@ -12203,16 +12256,36 @@ var isMediaWebRTCStatsReport = (value) => {
|
|
|
12203
12256
|
const report = value;
|
|
12204
12257
|
return !!report && typeof report === "object" && (report.status === "pass" || report.status === "warn" || report.status === "fail") && typeof report.activeCandidatePairs === "number" && typeof report.liveAudioTracks === "number" && typeof report.packetLossRatio === "number" && typeof report.bytesReceived === "number" && typeof report.bytesSent === "number" && Array.isArray(report.issues);
|
|
12205
12258
|
};
|
|
12259
|
+
var isMediaWebRTCStreamContinuityReport = (value) => {
|
|
12260
|
+
const report = value;
|
|
12261
|
+
return !!report && typeof report === "object" && (report.status === "pass" || report.status === "warn" || report.status === "fail") && typeof report.inboundAudioStreams === "number" && typeof report.outboundAudioStreams === "number" && typeof report.stalledInboundStreams === "number" && typeof report.stalledOutboundStreams === "number" && Array.isArray(report.issues) && Array.isArray(report.streams);
|
|
12262
|
+
};
|
|
12206
12263
|
var isBrowserMediaPostBody = (value) => {
|
|
12207
12264
|
const body = value;
|
|
12208
12265
|
return !!body && isMediaWebRTCStatsReport(body.report);
|
|
12209
12266
|
};
|
|
12267
|
+
var mergeIssues = (report, continuity) => {
|
|
12268
|
+
if (!continuity) {
|
|
12269
|
+
return report;
|
|
12270
|
+
}
|
|
12271
|
+
const issues = [
|
|
12272
|
+
...report.issues,
|
|
12273
|
+
...continuity.issues
|
|
12274
|
+
];
|
|
12275
|
+
const status = report.status === "fail" || continuity.status === "fail" ? "fail" : report.status === "warn" || continuity.status === "warn" ? "warn" : "pass";
|
|
12276
|
+
return {
|
|
12277
|
+
...report,
|
|
12278
|
+
issues,
|
|
12279
|
+
status
|
|
12280
|
+
};
|
|
12281
|
+
};
|
|
12210
12282
|
var toBrowserMediaSample = (event) => {
|
|
12211
12283
|
if (event.type !== "client.browser_media" || !isMediaWebRTCStatsReport(event.payload.report)) {
|
|
12212
12284
|
return;
|
|
12213
12285
|
}
|
|
12214
12286
|
return {
|
|
12215
12287
|
at: event.at,
|
|
12288
|
+
continuity: isMediaWebRTCStreamContinuityReport(event.payload.continuity) ? event.payload.continuity : undefined,
|
|
12216
12289
|
report: event.payload.report,
|
|
12217
12290
|
scenarioId: event.scenarioId,
|
|
12218
12291
|
sessionId: event.sessionId,
|
|
@@ -12228,24 +12301,29 @@ var summarizeVoiceBrowserMedia = async (options) => {
|
|
|
12228
12301
|
const latest = recent[0];
|
|
12229
12302
|
const maxAgeMs = options.maxAgeMs ?? 30000;
|
|
12230
12303
|
const stale = latest ? Date.now() - latest.at > maxAgeMs : false;
|
|
12304
|
+
const latestReport = latest ? mergeIssues(latest.report, latest.continuity) : undefined;
|
|
12231
12305
|
return {
|
|
12232
12306
|
checkedAt: Date.now(),
|
|
12233
12307
|
latest,
|
|
12234
12308
|
recent,
|
|
12235
12309
|
stale,
|
|
12236
|
-
status: latest ?
|
|
12310
|
+
status: latest ? latestReport?.status === "pass" && !stale ? "pass" : stale ? "warn" : latestReport?.status ?? latest.report.status : "empty",
|
|
12237
12311
|
total: recent.length
|
|
12238
12312
|
};
|
|
12239
12313
|
};
|
|
12240
12314
|
var getLatestVoiceBrowserMediaReport = async (options) => {
|
|
12241
12315
|
const summary = await summarizeVoiceBrowserMedia(options);
|
|
12242
|
-
return summary.latest
|
|
12316
|
+
return summary.latest ? mergeIssues(summary.latest.report, summary.latest.continuity) : undefined;
|
|
12243
12317
|
};
|
|
12244
12318
|
var renderVoiceBrowserMediaHTML = (report, options = {}) => {
|
|
12245
12319
|
const title = options.title ?? "Voice Browser Media";
|
|
12246
12320
|
const latest = report.latest?.report;
|
|
12247
|
-
const
|
|
12248
|
-
|
|
12321
|
+
const latestContinuity = report.latest?.continuity;
|
|
12322
|
+
const rows = report.recent.slice(0, 20).map((sample) => {
|
|
12323
|
+
const stalledStreams = (sample.continuity?.stalledInboundStreams ?? 0) + (sample.continuity?.stalledOutboundStreams ?? 0);
|
|
12324
|
+
return `<tr><td>${escapeHtml16(sample.sessionId)}</td><td>${escapeHtml16(mergeIssues(sample.report, sample.continuity).status)}</td><td>${String(sample.report.activeCandidatePairs)}</td><td>${String(sample.report.liveAudioTracks)}</td><td>${String(sample.continuity?.inboundAudioStreams ?? "n/a")}</td><td>${String(sample.continuity?.outboundAudioStreams ?? "n/a")}</td><td>${String(stalledStreams)}</td><td>${String(sample.report.roundTripTimeMs ?? "n/a")}ms</td><td>${String(sample.report.jitterMs ?? "n/a")}ms</td><td>${String(sample.report.packetLossRatio)}</td><td>${escapeHtml16(new Date(sample.at).toLocaleString())}</td></tr>`;
|
|
12325
|
+
}).join("");
|
|
12326
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>${escapeHtml16(title)}</title><style>body{background:#0f172a;color:#e2e8f0;font-family:ui-sans-serif,system-ui,sans-serif;margin:0}main{margin:auto;max-width:1120px;padding:32px}.hero,.primitive,table{background:#111827;border:1px solid #334155;border-radius:22px;margin-bottom:16px}.hero,.primitive{padding:22px}.eyebrow{color:#93c5fd;font-weight:900;letter-spacing:.12em;text-transform:uppercase}h1{font-size:clamp(2.3rem,6vw,4.8rem);line-height:.92;margin:.2rem 0 1rem}.status{border:1px solid #64748b;border-radius:999px;display:inline-flex;font-weight:900;padding:8px 12px}.pass{color:#86efac}.warn,.empty{color:#fde68a}.fail{color:#fecaca}.metrics{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));margin-top:18px}.metric{background:#0b1220;border:1px solid #263244;border-radius:16px;padding:14px}.metric span{color:#94a3b8}.metric strong{display:block;font-size:1.7rem;margin-top:4px}.primitive code{color:#bfdbfe}table{border-collapse:collapse;overflow:hidden;width:100%}td,th{border-bottom:1px solid #334155;padding:10px;text-align:left}</style></head><body><main><section class="hero"><p class="eyebrow">Browser WebRTC media proof</p><h1>${escapeHtml16(title)}</h1><p class="status ${escapeHtml16(report.status)}">Status: ${escapeHtml16(report.status)}</p><p>Recent <code>client.browser_media</code> traces from browser <code>RTCPeerConnection.getStats()</code> reports, including aggregate transport stats and per-stream continuity.</p><section class="metrics"><div class="metric"><span>Reports</span><strong>${String(report.total)}</strong></div><div class="metric"><span>Candidate pairs</span><strong>${String(latest?.activeCandidatePairs ?? 0)}</strong></div><div class="metric"><span>Live audio tracks</span><strong>${String(latest?.liveAudioTracks ?? 0)}</strong></div><div class="metric"><span>Inbound streams</span><strong>${String(latestContinuity?.inboundAudioStreams ?? "n/a")}</strong></div><div class="metric"><span>Outbound streams</span><strong>${String(latestContinuity?.outboundAudioStreams ?? "n/a")}</strong></div><div class="metric"><span>Stalled streams</span><strong>${String((latestContinuity?.stalledInboundStreams ?? 0) + (latestContinuity?.stalledOutboundStreams ?? 0))}</strong></div><div class="metric"><span>RTT</span><strong>${String(latest?.roundTripTimeMs ?? "n/a")}ms</strong></div><div class="metric"><span>Jitter</span><strong>${String(latest?.jitterMs ?? "n/a")}ms</strong></div><div class="metric"><span>Loss</span><strong>${String(latest?.packetLossRatio ?? "n/a")}</strong></div></section></section><section class="primitive"><p class="eyebrow">Copy into your app</p><p><code>createVoiceBrowserMediaReporter({ peerConnection, continuity })</code> runs in the browser and posts reports here. <code>getLatestVoiceBrowserMediaReport(...)</code> can feed production readiness with aggregate and continuity issues merged.</p></section><table><thead><tr><th>Session</th><th>Status</th><th>Pairs</th><th>Tracks</th><th>Inbound</th><th>Outbound</th><th>Stalled</th><th>RTT</th><th>Jitter</th><th>Loss</th><th>Measured</th></tr></thead><tbody>${rows || '<tr><td colspan="11">No browser media reports yet.</td></tr>'}</tbody></table></main></body></html>`;
|
|
12249
12327
|
};
|
|
12250
12328
|
var createVoiceBrowserMediaRoutes = (options) => {
|
|
12251
12329
|
const path = options.path ?? "/api/voice/browser-media";
|
|
@@ -12261,6 +12339,7 @@ var createVoiceBrowserMediaRoutes = (options) => {
|
|
|
12261
12339
|
await options.store.append({
|
|
12262
12340
|
at: typeof body.at === "number" ? body.at : Date.now(),
|
|
12263
12341
|
payload: {
|
|
12342
|
+
continuity: isMediaWebRTCStreamContinuityReport(body.continuity) ? body.continuity : undefined,
|
|
12264
12343
|
report: body.report
|
|
12265
12344
|
},
|
|
12266
12345
|
scenarioId: typeof body.scenarioId === "string" ? body.scenarioId : undefined,
|
package/dist/react/index.js
CHANGED
|
@@ -5048,6 +5048,7 @@ var stringStat = (stat, key) => {
|
|
|
5048
5048
|
const value = stat[key];
|
|
5049
5049
|
return typeof value === "string" ? value : undefined;
|
|
5050
5050
|
};
|
|
5051
|
+
var statKey = (stat) => String(stat.id ?? stringStat(stat, "ssrc") ?? numericStat(stat, "ssrc") ?? stringStat(stat, "trackIdentifier") ?? stringStat(stat, "mid") ?? "unknown");
|
|
5051
5052
|
var secondsToMs = (value) => value === undefined ? undefined : value * 1000;
|
|
5052
5053
|
var normalizeWebRTCStat = (stat) => {
|
|
5053
5054
|
const sample = {};
|
|
@@ -5294,12 +5295,64 @@ var collectMediaWebRTCStats = async (input) => {
|
|
|
5294
5295
|
const report = await input.peerConnection.getStats(input.selector ?? null);
|
|
5295
5296
|
return [...report.values()].map(normalizeWebRTCStat);
|
|
5296
5297
|
};
|
|
5297
|
-
var
|
|
5298
|
-
const stats =
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
|
|
5298
|
+
var buildMediaWebRTCStreamContinuityReport = (input = {}) => {
|
|
5299
|
+
const stats = input.stats ?? [];
|
|
5300
|
+
const previousStats = input.previousStats ?? [];
|
|
5301
|
+
const issues = [];
|
|
5302
|
+
const previousByKey = new Map(previousStats.map((stat) => [statKey(stat), stat]));
|
|
5303
|
+
const audioRtp = stats.filter((stat) => (stat.type === "inbound-rtp" || stat.type === "outbound-rtp") && stringStat(stat, "kind") !== "video" && stringStat(stat, "mediaType") !== "video");
|
|
5304
|
+
const streams = audioRtp.map((stat) => {
|
|
5305
|
+
const direction = stat.type === "outbound-rtp" ? "outbound" : "inbound";
|
|
5306
|
+
const packetsKey = direction === "outbound" ? "packetsSent" : "packetsReceived";
|
|
5307
|
+
const bytesKey = direction === "outbound" ? "bytesSent" : "bytesReceived";
|
|
5308
|
+
const previous = previousByKey.get(statKey(stat));
|
|
5309
|
+
const currentPackets = numericStat(stat, packetsKey);
|
|
5310
|
+
const previousPackets = previous ? numericStat(previous, packetsKey) : undefined;
|
|
5311
|
+
const currentBytes = numericStat(stat, bytesKey);
|
|
5312
|
+
const previousBytes = previous ? numericStat(previous, bytesKey) : undefined;
|
|
5313
|
+
const timeDeltaMs = stat.timestamp !== undefined && previous?.timestamp !== undefined ? stat.timestamp - previous.timestamp : undefined;
|
|
5314
|
+
return {
|
|
5315
|
+
bytesDelta: currentBytes !== undefined && previousBytes !== undefined ? currentBytes - previousBytes : undefined,
|
|
5316
|
+
currentPackets,
|
|
5317
|
+
direction,
|
|
5318
|
+
id: statKey(stat),
|
|
5319
|
+
packetDelta: currentPackets !== undefined && previousPackets !== undefined ? currentPackets - previousPackets : undefined,
|
|
5320
|
+
previousPackets,
|
|
5321
|
+
timeDeltaMs
|
|
5322
|
+
};
|
|
5302
5323
|
});
|
|
5324
|
+
const inbound = streams.filter((stream) => stream.direction === "inbound");
|
|
5325
|
+
const outbound = streams.filter((stream) => stream.direction === "outbound");
|
|
5326
|
+
const maxObservedGapMs = max(streams.map((stream) => stream.timeDeltaMs).filter((value) => value !== undefined));
|
|
5327
|
+
const stalledInboundStreams = inbound.filter((stream) => input.maxInboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxInboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
|
|
5328
|
+
const stalledOutboundStreams = outbound.filter((stream) => input.maxOutboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxOutboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
|
|
5329
|
+
if (input.requireInboundAudio && inbound.length === 0) {
|
|
5330
|
+
pushIssue(issues, "error", "media.webrtc_inbound_audio_missing", "No inbound WebRTC audio RTP stream was observed.");
|
|
5331
|
+
}
|
|
5332
|
+
if (input.requireOutboundAudio && outbound.length === 0) {
|
|
5333
|
+
pushIssue(issues, "error", "media.webrtc_outbound_audio_missing", "No outbound WebRTC audio RTP stream was observed.");
|
|
5334
|
+
}
|
|
5335
|
+
if (input.maxGapMs !== undefined && maxObservedGapMs !== undefined && maxObservedGapMs > input.maxGapMs) {
|
|
5336
|
+
pushIssue(issues, "warning", "media.webrtc_stream_gap", `Observed WebRTC stream sample gap ${String(maxObservedGapMs)}ms above ${String(input.maxGapMs)}ms.`);
|
|
5337
|
+
}
|
|
5338
|
+
if (stalledInboundStreams > 0) {
|
|
5339
|
+
pushIssue(issues, "error", "media.webrtc_inbound_stalled", `${String(stalledInboundStreams)} inbound WebRTC audio stream(s) stopped receiving packets.`);
|
|
5340
|
+
}
|
|
5341
|
+
if (stalledOutboundStreams > 0) {
|
|
5342
|
+
pushIssue(issues, "error", "media.webrtc_outbound_stalled", `${String(stalledOutboundStreams)} outbound WebRTC audio stream(s) stopped sending packets.`);
|
|
5343
|
+
}
|
|
5344
|
+
return {
|
|
5345
|
+
checkedAt: Date.now(),
|
|
5346
|
+
inboundAudioStreams: inbound.length,
|
|
5347
|
+
issues,
|
|
5348
|
+
maxObservedGapMs,
|
|
5349
|
+
outboundAudioStreams: outbound.length,
|
|
5350
|
+
stalledInboundStreams,
|
|
5351
|
+
stalledOutboundStreams,
|
|
5352
|
+
status: issues.some((issue) => issue.severity === "error") ? "fail" : issues.length > 0 ? "warn" : "pass",
|
|
5353
|
+
streams,
|
|
5354
|
+
totalStats: stats.length
|
|
5355
|
+
};
|
|
5303
5356
|
};
|
|
5304
5357
|
var buildMediaPipelineCalibrationReport = (input = {}) => {
|
|
5305
5358
|
const frames = input.frames ?? [];
|
|
@@ -5385,21 +5438,30 @@ var postBrowserMediaReport = async (payload, options) => {
|
|
|
5385
5438
|
};
|
|
5386
5439
|
var createVoiceBrowserMediaReporter = (options) => {
|
|
5387
5440
|
let interval = null;
|
|
5441
|
+
let previousStats = [];
|
|
5388
5442
|
const reportOnce = async () => {
|
|
5389
5443
|
const peerConnection = await resolvePeerConnection(options);
|
|
5390
5444
|
if (!peerConnection) {
|
|
5391
5445
|
return;
|
|
5392
5446
|
}
|
|
5393
|
-
const
|
|
5447
|
+
const stats = await collectMediaWebRTCStats({ peerConnection });
|
|
5448
|
+
const report = buildMediaWebRTCStatsReport({
|
|
5394
5449
|
...options,
|
|
5395
|
-
|
|
5450
|
+
stats
|
|
5451
|
+
});
|
|
5452
|
+
const continuity = options.continuity === false ? undefined : buildMediaWebRTCStreamContinuityReport({
|
|
5453
|
+
...options.continuity,
|
|
5454
|
+
previousStats,
|
|
5455
|
+
stats
|
|
5396
5456
|
});
|
|
5397
5457
|
const payload = {
|
|
5398
5458
|
at: Date.now(),
|
|
5459
|
+
continuity,
|
|
5399
5460
|
report,
|
|
5400
5461
|
scenarioId: options.getScenarioId?.() ?? null,
|
|
5401
5462
|
sessionId: options.getSessionId?.() ?? null
|
|
5402
5463
|
};
|
|
5464
|
+
previousStats = stats;
|
|
5403
5465
|
options.onReport?.(payload);
|
|
5404
5466
|
await postBrowserMediaReport(payload, options);
|
|
5405
5467
|
return payload;
|
package/dist/svelte/index.js
CHANGED
|
@@ -3382,6 +3382,7 @@ var stringStat = (stat, key) => {
|
|
|
3382
3382
|
const value = stat[key];
|
|
3383
3383
|
return typeof value === "string" ? value : undefined;
|
|
3384
3384
|
};
|
|
3385
|
+
var statKey = (stat) => String(stat.id ?? stringStat(stat, "ssrc") ?? numericStat(stat, "ssrc") ?? stringStat(stat, "trackIdentifier") ?? stringStat(stat, "mid") ?? "unknown");
|
|
3385
3386
|
var secondsToMs = (value) => value === undefined ? undefined : value * 1000;
|
|
3386
3387
|
var normalizeWebRTCStat = (stat) => {
|
|
3387
3388
|
const sample = {};
|
|
@@ -3628,12 +3629,64 @@ var collectMediaWebRTCStats = async (input) => {
|
|
|
3628
3629
|
const report = await input.peerConnection.getStats(input.selector ?? null);
|
|
3629
3630
|
return [...report.values()].map(normalizeWebRTCStat);
|
|
3630
3631
|
};
|
|
3631
|
-
var
|
|
3632
|
-
const stats =
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3632
|
+
var buildMediaWebRTCStreamContinuityReport = (input = {}) => {
|
|
3633
|
+
const stats = input.stats ?? [];
|
|
3634
|
+
const previousStats = input.previousStats ?? [];
|
|
3635
|
+
const issues = [];
|
|
3636
|
+
const previousByKey = new Map(previousStats.map((stat) => [statKey(stat), stat]));
|
|
3637
|
+
const audioRtp = stats.filter((stat) => (stat.type === "inbound-rtp" || stat.type === "outbound-rtp") && stringStat(stat, "kind") !== "video" && stringStat(stat, "mediaType") !== "video");
|
|
3638
|
+
const streams = audioRtp.map((stat) => {
|
|
3639
|
+
const direction = stat.type === "outbound-rtp" ? "outbound" : "inbound";
|
|
3640
|
+
const packetsKey = direction === "outbound" ? "packetsSent" : "packetsReceived";
|
|
3641
|
+
const bytesKey = direction === "outbound" ? "bytesSent" : "bytesReceived";
|
|
3642
|
+
const previous = previousByKey.get(statKey(stat));
|
|
3643
|
+
const currentPackets = numericStat(stat, packetsKey);
|
|
3644
|
+
const previousPackets = previous ? numericStat(previous, packetsKey) : undefined;
|
|
3645
|
+
const currentBytes = numericStat(stat, bytesKey);
|
|
3646
|
+
const previousBytes = previous ? numericStat(previous, bytesKey) : undefined;
|
|
3647
|
+
const timeDeltaMs = stat.timestamp !== undefined && previous?.timestamp !== undefined ? stat.timestamp - previous.timestamp : undefined;
|
|
3648
|
+
return {
|
|
3649
|
+
bytesDelta: currentBytes !== undefined && previousBytes !== undefined ? currentBytes - previousBytes : undefined,
|
|
3650
|
+
currentPackets,
|
|
3651
|
+
direction,
|
|
3652
|
+
id: statKey(stat),
|
|
3653
|
+
packetDelta: currentPackets !== undefined && previousPackets !== undefined ? currentPackets - previousPackets : undefined,
|
|
3654
|
+
previousPackets,
|
|
3655
|
+
timeDeltaMs
|
|
3656
|
+
};
|
|
3636
3657
|
});
|
|
3658
|
+
const inbound = streams.filter((stream) => stream.direction === "inbound");
|
|
3659
|
+
const outbound = streams.filter((stream) => stream.direction === "outbound");
|
|
3660
|
+
const maxObservedGapMs = max(streams.map((stream) => stream.timeDeltaMs).filter((value) => value !== undefined));
|
|
3661
|
+
const stalledInboundStreams = inbound.filter((stream) => input.maxInboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxInboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
|
|
3662
|
+
const stalledOutboundStreams = outbound.filter((stream) => input.maxOutboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxOutboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
|
|
3663
|
+
if (input.requireInboundAudio && inbound.length === 0) {
|
|
3664
|
+
pushIssue(issues, "error", "media.webrtc_inbound_audio_missing", "No inbound WebRTC audio RTP stream was observed.");
|
|
3665
|
+
}
|
|
3666
|
+
if (input.requireOutboundAudio && outbound.length === 0) {
|
|
3667
|
+
pushIssue(issues, "error", "media.webrtc_outbound_audio_missing", "No outbound WebRTC audio RTP stream was observed.");
|
|
3668
|
+
}
|
|
3669
|
+
if (input.maxGapMs !== undefined && maxObservedGapMs !== undefined && maxObservedGapMs > input.maxGapMs) {
|
|
3670
|
+
pushIssue(issues, "warning", "media.webrtc_stream_gap", `Observed WebRTC stream sample gap ${String(maxObservedGapMs)}ms above ${String(input.maxGapMs)}ms.`);
|
|
3671
|
+
}
|
|
3672
|
+
if (stalledInboundStreams > 0) {
|
|
3673
|
+
pushIssue(issues, "error", "media.webrtc_inbound_stalled", `${String(stalledInboundStreams)} inbound WebRTC audio stream(s) stopped receiving packets.`);
|
|
3674
|
+
}
|
|
3675
|
+
if (stalledOutboundStreams > 0) {
|
|
3676
|
+
pushIssue(issues, "error", "media.webrtc_outbound_stalled", `${String(stalledOutboundStreams)} outbound WebRTC audio stream(s) stopped sending packets.`);
|
|
3677
|
+
}
|
|
3678
|
+
return {
|
|
3679
|
+
checkedAt: Date.now(),
|
|
3680
|
+
inboundAudioStreams: inbound.length,
|
|
3681
|
+
issues,
|
|
3682
|
+
maxObservedGapMs,
|
|
3683
|
+
outboundAudioStreams: outbound.length,
|
|
3684
|
+
stalledInboundStreams,
|
|
3685
|
+
stalledOutboundStreams,
|
|
3686
|
+
status: issues.some((issue) => issue.severity === "error") ? "fail" : issues.length > 0 ? "warn" : "pass",
|
|
3687
|
+
streams,
|
|
3688
|
+
totalStats: stats.length
|
|
3689
|
+
};
|
|
3637
3690
|
};
|
|
3638
3691
|
var buildMediaPipelineCalibrationReport = (input = {}) => {
|
|
3639
3692
|
const frames = input.frames ?? [];
|
|
@@ -3719,21 +3772,30 @@ var postBrowserMediaReport = async (payload, options) => {
|
|
|
3719
3772
|
};
|
|
3720
3773
|
var createVoiceBrowserMediaReporter = (options) => {
|
|
3721
3774
|
let interval = null;
|
|
3775
|
+
let previousStats = [];
|
|
3722
3776
|
const reportOnce = async () => {
|
|
3723
3777
|
const peerConnection = await resolvePeerConnection(options);
|
|
3724
3778
|
if (!peerConnection) {
|
|
3725
3779
|
return;
|
|
3726
3780
|
}
|
|
3727
|
-
const
|
|
3781
|
+
const stats = await collectMediaWebRTCStats({ peerConnection });
|
|
3782
|
+
const report = buildMediaWebRTCStatsReport({
|
|
3728
3783
|
...options,
|
|
3729
|
-
|
|
3784
|
+
stats
|
|
3785
|
+
});
|
|
3786
|
+
const continuity = options.continuity === false ? undefined : buildMediaWebRTCStreamContinuityReport({
|
|
3787
|
+
...options.continuity,
|
|
3788
|
+
previousStats,
|
|
3789
|
+
stats
|
|
3730
3790
|
});
|
|
3731
3791
|
const payload = {
|
|
3732
3792
|
at: Date.now(),
|
|
3793
|
+
continuity,
|
|
3733
3794
|
report,
|
|
3734
3795
|
scenarioId: options.getScenarioId?.() ?? null,
|
|
3735
3796
|
sessionId: options.getSessionId?.() ?? null
|
|
3736
3797
|
};
|
|
3798
|
+
previousStats = stats;
|
|
3737
3799
|
options.onReport?.(payload);
|
|
3738
3800
|
await postBrowserMediaReport(payload, options);
|
|
3739
3801
|
return payload;
|
package/dist/testing/index.js
CHANGED
|
@@ -2184,6 +2184,7 @@ var stringStat = (stat, key) => {
|
|
|
2184
2184
|
const value = stat[key];
|
|
2185
2185
|
return typeof value === "string" ? value : undefined;
|
|
2186
2186
|
};
|
|
2187
|
+
var statKey = (stat) => String(stat.id ?? stringStat(stat, "ssrc") ?? numericStat(stat, "ssrc") ?? stringStat(stat, "trackIdentifier") ?? stringStat(stat, "mid") ?? "unknown");
|
|
2187
2188
|
var secondsToMs = (value) => value === undefined ? undefined : value * 1000;
|
|
2188
2189
|
var normalizeWebRTCStat = (stat) => {
|
|
2189
2190
|
const sample = {};
|
|
@@ -2430,12 +2431,64 @@ var collectMediaWebRTCStats = async (input) => {
|
|
|
2430
2431
|
const report = await input.peerConnection.getStats(input.selector ?? null);
|
|
2431
2432
|
return [...report.values()].map(normalizeWebRTCStat);
|
|
2432
2433
|
};
|
|
2433
|
-
var
|
|
2434
|
-
const stats =
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2434
|
+
var buildMediaWebRTCStreamContinuityReport = (input = {}) => {
|
|
2435
|
+
const stats = input.stats ?? [];
|
|
2436
|
+
const previousStats = input.previousStats ?? [];
|
|
2437
|
+
const issues = [];
|
|
2438
|
+
const previousByKey = new Map(previousStats.map((stat) => [statKey(stat), stat]));
|
|
2439
|
+
const audioRtp = stats.filter((stat) => (stat.type === "inbound-rtp" || stat.type === "outbound-rtp") && stringStat(stat, "kind") !== "video" && stringStat(stat, "mediaType") !== "video");
|
|
2440
|
+
const streams = audioRtp.map((stat) => {
|
|
2441
|
+
const direction = stat.type === "outbound-rtp" ? "outbound" : "inbound";
|
|
2442
|
+
const packetsKey = direction === "outbound" ? "packetsSent" : "packetsReceived";
|
|
2443
|
+
const bytesKey = direction === "outbound" ? "bytesSent" : "bytesReceived";
|
|
2444
|
+
const previous = previousByKey.get(statKey(stat));
|
|
2445
|
+
const currentPackets = numericStat(stat, packetsKey);
|
|
2446
|
+
const previousPackets = previous ? numericStat(previous, packetsKey) : undefined;
|
|
2447
|
+
const currentBytes = numericStat(stat, bytesKey);
|
|
2448
|
+
const previousBytes = previous ? numericStat(previous, bytesKey) : undefined;
|
|
2449
|
+
const timeDeltaMs = stat.timestamp !== undefined && previous?.timestamp !== undefined ? stat.timestamp - previous.timestamp : undefined;
|
|
2450
|
+
return {
|
|
2451
|
+
bytesDelta: currentBytes !== undefined && previousBytes !== undefined ? currentBytes - previousBytes : undefined,
|
|
2452
|
+
currentPackets,
|
|
2453
|
+
direction,
|
|
2454
|
+
id: statKey(stat),
|
|
2455
|
+
packetDelta: currentPackets !== undefined && previousPackets !== undefined ? currentPackets - previousPackets : undefined,
|
|
2456
|
+
previousPackets,
|
|
2457
|
+
timeDeltaMs
|
|
2458
|
+
};
|
|
2438
2459
|
});
|
|
2460
|
+
const inbound = streams.filter((stream) => stream.direction === "inbound");
|
|
2461
|
+
const outbound = streams.filter((stream) => stream.direction === "outbound");
|
|
2462
|
+
const maxObservedGapMs = max(streams.map((stream) => stream.timeDeltaMs).filter((value) => value !== undefined));
|
|
2463
|
+
const stalledInboundStreams = inbound.filter((stream) => input.maxInboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxInboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
|
|
2464
|
+
const stalledOutboundStreams = outbound.filter((stream) => input.maxOutboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxOutboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
|
|
2465
|
+
if (input.requireInboundAudio && inbound.length === 0) {
|
|
2466
|
+
pushIssue(issues, "error", "media.webrtc_inbound_audio_missing", "No inbound WebRTC audio RTP stream was observed.");
|
|
2467
|
+
}
|
|
2468
|
+
if (input.requireOutboundAudio && outbound.length === 0) {
|
|
2469
|
+
pushIssue(issues, "error", "media.webrtc_outbound_audio_missing", "No outbound WebRTC audio RTP stream was observed.");
|
|
2470
|
+
}
|
|
2471
|
+
if (input.maxGapMs !== undefined && maxObservedGapMs !== undefined && maxObservedGapMs > input.maxGapMs) {
|
|
2472
|
+
pushIssue(issues, "warning", "media.webrtc_stream_gap", `Observed WebRTC stream sample gap ${String(maxObservedGapMs)}ms above ${String(input.maxGapMs)}ms.`);
|
|
2473
|
+
}
|
|
2474
|
+
if (stalledInboundStreams > 0) {
|
|
2475
|
+
pushIssue(issues, "error", "media.webrtc_inbound_stalled", `${String(stalledInboundStreams)} inbound WebRTC audio stream(s) stopped receiving packets.`);
|
|
2476
|
+
}
|
|
2477
|
+
if (stalledOutboundStreams > 0) {
|
|
2478
|
+
pushIssue(issues, "error", "media.webrtc_outbound_stalled", `${String(stalledOutboundStreams)} outbound WebRTC audio stream(s) stopped sending packets.`);
|
|
2479
|
+
}
|
|
2480
|
+
return {
|
|
2481
|
+
checkedAt: Date.now(),
|
|
2482
|
+
inboundAudioStreams: inbound.length,
|
|
2483
|
+
issues,
|
|
2484
|
+
maxObservedGapMs,
|
|
2485
|
+
outboundAudioStreams: outbound.length,
|
|
2486
|
+
stalledInboundStreams,
|
|
2487
|
+
stalledOutboundStreams,
|
|
2488
|
+
status: issues.some((issue) => issue.severity === "error") ? "fail" : issues.length > 0 ? "warn" : "pass",
|
|
2489
|
+
streams,
|
|
2490
|
+
totalStats: stats.length
|
|
2491
|
+
};
|
|
2439
2492
|
};
|
|
2440
2493
|
var buildMediaPipelineCalibrationReport = (input = {}) => {
|
|
2441
2494
|
const frames = input.frames ?? [];
|
|
@@ -2521,21 +2574,30 @@ var postBrowserMediaReport = async (payload, options) => {
|
|
|
2521
2574
|
};
|
|
2522
2575
|
var createVoiceBrowserMediaReporter = (options) => {
|
|
2523
2576
|
let interval = null;
|
|
2577
|
+
let previousStats = [];
|
|
2524
2578
|
const reportOnce = async () => {
|
|
2525
2579
|
const peerConnection = await resolvePeerConnection(options);
|
|
2526
2580
|
if (!peerConnection) {
|
|
2527
2581
|
return;
|
|
2528
2582
|
}
|
|
2529
|
-
const
|
|
2583
|
+
const stats = await collectMediaWebRTCStats({ peerConnection });
|
|
2584
|
+
const report = buildMediaWebRTCStatsReport({
|
|
2530
2585
|
...options,
|
|
2531
|
-
|
|
2586
|
+
stats
|
|
2587
|
+
});
|
|
2588
|
+
const continuity = options.continuity === false ? undefined : buildMediaWebRTCStreamContinuityReport({
|
|
2589
|
+
...options.continuity,
|
|
2590
|
+
previousStats,
|
|
2591
|
+
stats
|
|
2532
2592
|
});
|
|
2533
2593
|
const payload = {
|
|
2534
2594
|
at: Date.now(),
|
|
2595
|
+
continuity,
|
|
2535
2596
|
report,
|
|
2536
2597
|
scenarioId: options.getScenarioId?.() ?? null,
|
|
2537
2598
|
sessionId: options.getSessionId?.() ?? null
|
|
2538
2599
|
};
|
|
2600
|
+
previousStats = stats;
|
|
2539
2601
|
options.onReport?.(payload);
|
|
2540
2602
|
await postBrowserMediaReport(payload, options);
|
|
2541
2603
|
return payload;
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SessionStore } from '@absolutejs/absolute';
|
|
2
|
-
import type { MediaWebRTCStatsCollector, MediaWebRTCStatsReport, MediaWebRTCStatsReportInput } from '@absolutejs/media';
|
|
2
|
+
import type { MediaWebRTCStatsCollector, MediaWebRTCStatsReport, MediaWebRTCStatsReportInput, MediaWebRTCStreamContinuityInput, MediaWebRTCStreamContinuityReport } from '@absolutejs/media';
|
|
3
3
|
import type { VoiceOpsDispositionTaskPolicies, VoiceOpsTaskAssignmentRule, VoiceOpsTaskAssignmentRules, VoiceIntegrationWebhookConfig, StoredVoiceIntegrationEvent, StoredVoiceOpsTask, VoiceIntegrationEventStore, VoiceOpsTaskPolicy, VoiceOpsTask, VoiceOpsTaskStore } from './ops';
|
|
4
4
|
import type { VoiceIntegrationSink } from './opsSinks';
|
|
5
5
|
import type { StoredVoiceCallReviewArtifact, VoiceCallReviewArtifact, VoiceCallReviewStore } from './testing/review';
|
|
@@ -774,6 +774,7 @@ export type VoiceConnectionOptions = {
|
|
|
774
774
|
};
|
|
775
775
|
export type VoiceBrowserMediaReportPayload = {
|
|
776
776
|
at: number;
|
|
777
|
+
continuity?: MediaWebRTCStreamContinuityReport;
|
|
777
778
|
report: MediaWebRTCStatsReport;
|
|
778
779
|
scenarioId?: string | null;
|
|
779
780
|
sessionId?: string | null;
|
|
@@ -784,6 +785,7 @@ export type VoiceBrowserMediaReporterOptions = Omit<MediaWebRTCStatsReportInput,
|
|
|
784
785
|
getScenarioId?: () => string | null | undefined;
|
|
785
786
|
getSessionId?: () => string | null | undefined;
|
|
786
787
|
intervalMs?: number;
|
|
788
|
+
continuity?: false | Omit<MediaWebRTCStreamContinuityInput, 'previousStats' | 'stats'>;
|
|
787
789
|
onError?: (error: unknown) => void;
|
|
788
790
|
onReport?: (payload: VoiceBrowserMediaReportPayload) => void;
|
|
789
791
|
path?: string;
|
package/dist/vue/index.js
CHANGED
|
@@ -4766,6 +4766,7 @@ var stringStat = (stat, key) => {
|
|
|
4766
4766
|
const value = stat[key];
|
|
4767
4767
|
return typeof value === "string" ? value : undefined;
|
|
4768
4768
|
};
|
|
4769
|
+
var statKey = (stat) => String(stat.id ?? stringStat(stat, "ssrc") ?? numericStat(stat, "ssrc") ?? stringStat(stat, "trackIdentifier") ?? stringStat(stat, "mid") ?? "unknown");
|
|
4769
4770
|
var secondsToMs = (value) => value === undefined ? undefined : value * 1000;
|
|
4770
4771
|
var normalizeWebRTCStat = (stat) => {
|
|
4771
4772
|
const sample = {};
|
|
@@ -5012,12 +5013,64 @@ var collectMediaWebRTCStats = async (input) => {
|
|
|
5012
5013
|
const report = await input.peerConnection.getStats(input.selector ?? null);
|
|
5013
5014
|
return [...report.values()].map(normalizeWebRTCStat);
|
|
5014
5015
|
};
|
|
5015
|
-
var
|
|
5016
|
-
const stats =
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
5016
|
+
var buildMediaWebRTCStreamContinuityReport = (input = {}) => {
|
|
5017
|
+
const stats = input.stats ?? [];
|
|
5018
|
+
const previousStats = input.previousStats ?? [];
|
|
5019
|
+
const issues = [];
|
|
5020
|
+
const previousByKey = new Map(previousStats.map((stat) => [statKey(stat), stat]));
|
|
5021
|
+
const audioRtp = stats.filter((stat) => (stat.type === "inbound-rtp" || stat.type === "outbound-rtp") && stringStat(stat, "kind") !== "video" && stringStat(stat, "mediaType") !== "video");
|
|
5022
|
+
const streams = audioRtp.map((stat) => {
|
|
5023
|
+
const direction = stat.type === "outbound-rtp" ? "outbound" : "inbound";
|
|
5024
|
+
const packetsKey = direction === "outbound" ? "packetsSent" : "packetsReceived";
|
|
5025
|
+
const bytesKey = direction === "outbound" ? "bytesSent" : "bytesReceived";
|
|
5026
|
+
const previous = previousByKey.get(statKey(stat));
|
|
5027
|
+
const currentPackets = numericStat(stat, packetsKey);
|
|
5028
|
+
const previousPackets = previous ? numericStat(previous, packetsKey) : undefined;
|
|
5029
|
+
const currentBytes = numericStat(stat, bytesKey);
|
|
5030
|
+
const previousBytes = previous ? numericStat(previous, bytesKey) : undefined;
|
|
5031
|
+
const timeDeltaMs = stat.timestamp !== undefined && previous?.timestamp !== undefined ? stat.timestamp - previous.timestamp : undefined;
|
|
5032
|
+
return {
|
|
5033
|
+
bytesDelta: currentBytes !== undefined && previousBytes !== undefined ? currentBytes - previousBytes : undefined,
|
|
5034
|
+
currentPackets,
|
|
5035
|
+
direction,
|
|
5036
|
+
id: statKey(stat),
|
|
5037
|
+
packetDelta: currentPackets !== undefined && previousPackets !== undefined ? currentPackets - previousPackets : undefined,
|
|
5038
|
+
previousPackets,
|
|
5039
|
+
timeDeltaMs
|
|
5040
|
+
};
|
|
5020
5041
|
});
|
|
5042
|
+
const inbound = streams.filter((stream) => stream.direction === "inbound");
|
|
5043
|
+
const outbound = streams.filter((stream) => stream.direction === "outbound");
|
|
5044
|
+
const maxObservedGapMs = max(streams.map((stream) => stream.timeDeltaMs).filter((value) => value !== undefined));
|
|
5045
|
+
const stalledInboundStreams = inbound.filter((stream) => input.maxInboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxInboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
|
|
5046
|
+
const stalledOutboundStreams = outbound.filter((stream) => input.maxOutboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxOutboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
|
|
5047
|
+
if (input.requireInboundAudio && inbound.length === 0) {
|
|
5048
|
+
pushIssue(issues, "error", "media.webrtc_inbound_audio_missing", "No inbound WebRTC audio RTP stream was observed.");
|
|
5049
|
+
}
|
|
5050
|
+
if (input.requireOutboundAudio && outbound.length === 0) {
|
|
5051
|
+
pushIssue(issues, "error", "media.webrtc_outbound_audio_missing", "No outbound WebRTC audio RTP stream was observed.");
|
|
5052
|
+
}
|
|
5053
|
+
if (input.maxGapMs !== undefined && maxObservedGapMs !== undefined && maxObservedGapMs > input.maxGapMs) {
|
|
5054
|
+
pushIssue(issues, "warning", "media.webrtc_stream_gap", `Observed WebRTC stream sample gap ${String(maxObservedGapMs)}ms above ${String(input.maxGapMs)}ms.`);
|
|
5055
|
+
}
|
|
5056
|
+
if (stalledInboundStreams > 0) {
|
|
5057
|
+
pushIssue(issues, "error", "media.webrtc_inbound_stalled", `${String(stalledInboundStreams)} inbound WebRTC audio stream(s) stopped receiving packets.`);
|
|
5058
|
+
}
|
|
5059
|
+
if (stalledOutboundStreams > 0) {
|
|
5060
|
+
pushIssue(issues, "error", "media.webrtc_outbound_stalled", `${String(stalledOutboundStreams)} outbound WebRTC audio stream(s) stopped sending packets.`);
|
|
5061
|
+
}
|
|
5062
|
+
return {
|
|
5063
|
+
checkedAt: Date.now(),
|
|
5064
|
+
inboundAudioStreams: inbound.length,
|
|
5065
|
+
issues,
|
|
5066
|
+
maxObservedGapMs,
|
|
5067
|
+
outboundAudioStreams: outbound.length,
|
|
5068
|
+
stalledInboundStreams,
|
|
5069
|
+
stalledOutboundStreams,
|
|
5070
|
+
status: issues.some((issue) => issue.severity === "error") ? "fail" : issues.length > 0 ? "warn" : "pass",
|
|
5071
|
+
streams,
|
|
5072
|
+
totalStats: stats.length
|
|
5073
|
+
};
|
|
5021
5074
|
};
|
|
5022
5075
|
var buildMediaPipelineCalibrationReport = (input = {}) => {
|
|
5023
5076
|
const frames = input.frames ?? [];
|
|
@@ -5103,21 +5156,30 @@ var postBrowserMediaReport = async (payload, options) => {
|
|
|
5103
5156
|
};
|
|
5104
5157
|
var createVoiceBrowserMediaReporter = (options) => {
|
|
5105
5158
|
let interval = null;
|
|
5159
|
+
let previousStats = [];
|
|
5106
5160
|
const reportOnce = async () => {
|
|
5107
5161
|
const peerConnection = await resolvePeerConnection(options);
|
|
5108
5162
|
if (!peerConnection) {
|
|
5109
5163
|
return;
|
|
5110
5164
|
}
|
|
5111
|
-
const
|
|
5165
|
+
const stats = await collectMediaWebRTCStats({ peerConnection });
|
|
5166
|
+
const report = buildMediaWebRTCStatsReport({
|
|
5112
5167
|
...options,
|
|
5113
|
-
|
|
5168
|
+
stats
|
|
5169
|
+
});
|
|
5170
|
+
const continuity = options.continuity === false ? undefined : buildMediaWebRTCStreamContinuityReport({
|
|
5171
|
+
...options.continuity,
|
|
5172
|
+
previousStats,
|
|
5173
|
+
stats
|
|
5114
5174
|
});
|
|
5115
5175
|
const payload = {
|
|
5116
5176
|
at: Date.now(),
|
|
5177
|
+
continuity,
|
|
5117
5178
|
report,
|
|
5118
5179
|
scenarioId: options.getScenarioId?.() ?? null,
|
|
5119
5180
|
sessionId: options.getSessionId?.() ?? null
|
|
5120
5181
|
};
|
|
5182
|
+
previousStats = stats;
|
|
5121
5183
|
options.onReport?.(payload);
|
|
5122
5184
|
await postBrowserMediaReport(payload, options);
|
|
5123
5185
|
return payload;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@absolutejs/voice",
|
|
3
|
-
"version": "0.0.22-beta.
|
|
3
|
+
"version": "0.0.22-beta.322",
|
|
4
4
|
"description": "Voice primitives and Elysia plugin for AbsoluteJS",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -246,7 +246,7 @@
|
|
|
246
246
|
}
|
|
247
247
|
},
|
|
248
248
|
"dependencies": {
|
|
249
|
-
"@absolutejs/media": "0.0.1-beta.
|
|
249
|
+
"@absolutejs/media": "0.0.1-beta.5"
|
|
250
250
|
},
|
|
251
251
|
"devDependencies": {
|
|
252
252
|
"@absolutejs/absolute": "0.19.0-beta.646",
|