@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 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
+ ```
@@ -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 collectMediaWebRTCStatsReport = async (input) => {
1662
- const stats = await collectMediaWebRTCStats(input);
1663
- return buildMediaWebRTCStatsReport({
1664
- ...input,
1665
- stats
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 report = await collectMediaWebRTCStatsReport({
1811
+ const stats = await collectMediaWebRTCStats({ peerConnection });
1812
+ const report = buildMediaWebRTCStatsReport({
1758
1813
  ...options,
1759
- peerConnection
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 collectMediaWebRTCStatsReport = async (input) => {
338
- const stats = await collectMediaWebRTCStats(input);
339
- return buildMediaWebRTCStatsReport({
340
- ...input,
341
- stats
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 report = await collectMediaWebRTCStatsReport({
424
+ const stats = await collectMediaWebRTCStats({ peerConnection });
425
+ const report = buildMediaWebRTCStatsReport({
371
426
  ...options,
372
- peerConnection
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;
@@ -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 collectMediaWebRTCStatsReport = async (input) => {
1055
- const stats = await collectMediaWebRTCStats(input);
1056
- return buildMediaWebRTCStatsReport({
1057
- ...input,
1058
- stats
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 report = await collectMediaWebRTCStatsReport({
1204
+ const stats = await collectMediaWebRTCStats({ peerConnection });
1205
+ const report = buildMediaWebRTCStatsReport({
1151
1206
  ...options,
1152
- peerConnection
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 collectMediaWebRTCStatsReport = async (input) => {
11887
- const stats = await collectMediaWebRTCStats(input);
11888
- return buildMediaWebRTCStatsReport({
11889
- ...input,
11890
- stats
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 ? latest.report.status === "pass" && !stale ? "pass" : stale ? "warn" : latest.report.status : "empty",
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?.report;
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 rows = report.recent.slice(0, 20).map((sample) => `<tr><td>${escapeHtml16(sample.sessionId)}</td><td>${escapeHtml16(sample.report.status)}</td><td>${String(sample.report.activeCandidatePairs)}</td><td>${String(sample.report.liveAudioTracks)}</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>`).join("");
12248
- 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.</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>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 })</code> runs in the browser and posts reports here. <code>getLatestVoiceBrowserMediaReport(...)</code> can feed production readiness.</p></section><table><thead><tr><th>Session</th><th>Status</th><th>Pairs</th><th>Tracks</th><th>RTT</th><th>Jitter</th><th>Loss</th><th>Measured</th></tr></thead><tbody>${rows || '<tr><td colspan="8">No browser media reports yet.</td></tr>'}</tbody></table></main></body></html>`;
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,
@@ -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 collectMediaWebRTCStatsReport = async (input) => {
5298
- const stats = await collectMediaWebRTCStats(input);
5299
- return buildMediaWebRTCStatsReport({
5300
- ...input,
5301
- stats
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 report = await collectMediaWebRTCStatsReport({
5447
+ const stats = await collectMediaWebRTCStats({ peerConnection });
5448
+ const report = buildMediaWebRTCStatsReport({
5394
5449
  ...options,
5395
- peerConnection
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;
@@ -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 collectMediaWebRTCStatsReport = async (input) => {
3632
- const stats = await collectMediaWebRTCStats(input);
3633
- return buildMediaWebRTCStatsReport({
3634
- ...input,
3635
- stats
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 report = await collectMediaWebRTCStatsReport({
3781
+ const stats = await collectMediaWebRTCStats({ peerConnection });
3782
+ const report = buildMediaWebRTCStatsReport({
3728
3783
  ...options,
3729
- peerConnection
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;
@@ -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 collectMediaWebRTCStatsReport = async (input) => {
2434
- const stats = await collectMediaWebRTCStats(input);
2435
- return buildMediaWebRTCStatsReport({
2436
- ...input,
2437
- stats
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 report = await collectMediaWebRTCStatsReport({
2583
+ const stats = await collectMediaWebRTCStats({ peerConnection });
2584
+ const report = buildMediaWebRTCStatsReport({
2530
2585
  ...options,
2531
- peerConnection
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 collectMediaWebRTCStatsReport = async (input) => {
5016
- const stats = await collectMediaWebRTCStats(input);
5017
- return buildMediaWebRTCStatsReport({
5018
- ...input,
5019
- stats
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 report = await collectMediaWebRTCStatsReport({
5165
+ const stats = await collectMediaWebRTCStats({ peerConnection });
5166
+ const report = buildMediaWebRTCStatsReport({
5112
5167
  ...options,
5113
- peerConnection
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.320",
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.4"
249
+ "@absolutejs/media": "0.0.1-beta.5"
250
250
  },
251
251
  "devDependencies": {
252
252
  "@absolutejs/absolute": "0.19.0-beta.646",