@absolutejs/voice 0.0.22-beta.321 → 0.0.22-beta.323

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5048,7 +5048,65 @@ 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;
5053
+ var DEFAULT_TELEPHONY_FORMAT = {
5054
+ channels: 1,
5055
+ container: "raw",
5056
+ encoding: "mulaw",
5057
+ sampleRateHz: 8000
5058
+ };
5059
+ var bytesToBase64 = (audio) => {
5060
+ const bytes = audio instanceof ArrayBuffer ? new Uint8Array(audio) : new Uint8Array(audio.buffer, audio.byteOffset, audio.byteLength);
5061
+ return Buffer.from(bytes).toString("base64");
5062
+ };
5063
+ var base64ToBytes = (value) => new Uint8Array(Buffer.from(value, "base64"));
5064
+ var unknownRecord = (value) => value && typeof value === "object" ? value : {};
5065
+ var firstString = (records, keys) => {
5066
+ for (const record of records) {
5067
+ for (const key of keys) {
5068
+ const value = record[key];
5069
+ if (typeof value === "string" && value.length > 0) {
5070
+ return value;
5071
+ }
5072
+ if (typeof value === "number" && Number.isFinite(value)) {
5073
+ return String(value);
5074
+ }
5075
+ }
5076
+ }
5077
+ return;
5078
+ };
5079
+ var firstNumber = (records, keys) => {
5080
+ for (const record of records) {
5081
+ for (const key of keys) {
5082
+ const value = record[key];
5083
+ if (typeof value === "number" && Number.isFinite(value)) {
5084
+ return value;
5085
+ }
5086
+ if (typeof value === "string") {
5087
+ const parsed = Number(value);
5088
+ if (Number.isFinite(parsed)) {
5089
+ return parsed;
5090
+ }
5091
+ }
5092
+ }
5093
+ }
5094
+ return;
5095
+ };
5096
+ var telephonyDirection = (track) => {
5097
+ const normalized = track?.toLowerCase();
5098
+ if (!normalized) {
5099
+ return "unknown";
5100
+ }
5101
+ if (normalized.includes("inbound") || normalized.includes("caller") || normalized.includes("in")) {
5102
+ return "inbound";
5103
+ }
5104
+ if (normalized.includes("outbound") || normalized.includes("assistant") || normalized.includes("out")) {
5105
+ return "outbound";
5106
+ }
5107
+ return "unknown";
5108
+ };
5109
+ var telephonyFrameKind = (direction) => direction === "outbound" ? "assistant-audio" : "input-audio";
5052
5110
  var normalizeWebRTCStat = (stat) => {
5053
5111
  const sample = {};
5054
5112
  for (const [key, value] of Object.entries(stat)) {
@@ -5058,6 +5116,113 @@ var normalizeWebRTCStat = (stat) => {
5058
5116
  }
5059
5117
  return sample;
5060
5118
  };
5119
+ var parseTelephonyMediaFrame = (input) => {
5120
+ const envelope = input.envelope;
5121
+ const media = unknownRecord(envelope.media);
5122
+ const payload = firstString([media, envelope], ["payload", "audio", "data"]) ?? firstString([unknownRecord(envelope.message)], ["payload"]);
5123
+ if (!payload) {
5124
+ return;
5125
+ }
5126
+ const carrier = input.carrier ?? firstString([envelope], ["provider"]) ?? "telephony";
5127
+ const streamId = firstString([media, envelope], ["streamSid", "stream_id", "streamId", "streamId", "callSid", "call_id"]);
5128
+ const sequenceNumber = firstString([media, envelope], ["sequenceNumber", "sequence_number", "chunk"]);
5129
+ const track = firstString([media, envelope], ["track", "direction"]);
5130
+ const direction = telephonyDirection(track);
5131
+ const timestamp = firstNumber([media, envelope], ["timestamp", "time", "startedAt"]);
5132
+ return {
5133
+ at: timestamp,
5134
+ audio: base64ToBytes(payload),
5135
+ format: input.format ?? DEFAULT_TELEPHONY_FORMAT,
5136
+ id: [
5137
+ carrier,
5138
+ streamId ?? input.sessionId ?? "stream",
5139
+ sequenceNumber ?? timestamp ?? Date.now()
5140
+ ].join(":"),
5141
+ kind: telephonyFrameKind(direction),
5142
+ metadata: {
5143
+ carrier,
5144
+ direction,
5145
+ event: firstString([envelope], ["event", "type"]),
5146
+ sequenceNumber,
5147
+ streamId,
5148
+ track
5149
+ },
5150
+ sessionId: input.sessionId ?? streamId,
5151
+ source: "telephony"
5152
+ };
5153
+ };
5154
+ var serializeTelephonyMediaFrame = (input) => {
5155
+ const carrier = input.carrier ?? input.frame.metadata?.carrier ?? "telephony";
5156
+ const streamId = input.streamId ?? (typeof input.frame.metadata?.streamId === "string" ? input.frame.metadata.streamId : input.frame.sessionId);
5157
+ const sequenceNumber = input.sequenceNumber ?? (typeof input.frame.metadata?.sequenceNumber === "string" || typeof input.frame.metadata?.sequenceNumber === "number" ? input.frame.metadata.sequenceNumber : undefined);
5158
+ const direction = input.frame.kind === "assistant-audio" ? "outbound" : "inbound";
5159
+ const payload = input.frame.audio ? bytesToBase64(input.frame.audio) : "";
5160
+ if (carrier === "twilio") {
5161
+ return {
5162
+ event: "media",
5163
+ sequenceNumber,
5164
+ streamSid: streamId,
5165
+ media: {
5166
+ payload,
5167
+ timestamp: input.frame.at,
5168
+ track: direction
5169
+ }
5170
+ };
5171
+ }
5172
+ if (carrier === "telnyx") {
5173
+ return {
5174
+ event: "media",
5175
+ stream_id: streamId,
5176
+ sequence_number: sequenceNumber,
5177
+ media: {
5178
+ payload,
5179
+ timestamp: input.frame.at,
5180
+ track: direction
5181
+ }
5182
+ };
5183
+ }
5184
+ if (carrier === "plivo") {
5185
+ return {
5186
+ event: "media",
5187
+ streamId,
5188
+ sequenceNumber,
5189
+ media: {
5190
+ payload,
5191
+ timestamp: input.frame.at,
5192
+ track: direction
5193
+ }
5194
+ };
5195
+ }
5196
+ return {
5197
+ event: "media",
5198
+ provider: carrier,
5199
+ sequenceNumber,
5200
+ streamId,
5201
+ media: {
5202
+ payload,
5203
+ timestamp: input.frame.at,
5204
+ track: direction
5205
+ }
5206
+ };
5207
+ };
5208
+ var createTelephonyMediaSerializer = (input) => {
5209
+ const format = input.format ?? DEFAULT_TELEPHONY_FORMAT;
5210
+ return {
5211
+ carrier: input.carrier,
5212
+ format,
5213
+ parse: (envelope) => parseTelephonyMediaFrame({
5214
+ carrier: input.carrier,
5215
+ envelope,
5216
+ format,
5217
+ sessionId: input.sessionId ?? input.streamId
5218
+ }),
5219
+ serialize: (frame) => serializeTelephonyMediaFrame({
5220
+ carrier: input.carrier,
5221
+ frame,
5222
+ streamId: input.streamId
5223
+ })
5224
+ };
5225
+ };
5061
5226
  var buildMediaResamplingPlan = (input) => {
5062
5227
  const required = !formatMatches(input.inputFormat, input.outputFormat);
5063
5228
  return {
@@ -5294,12 +5459,64 @@ var collectMediaWebRTCStats = async (input) => {
5294
5459
  const report = await input.peerConnection.getStats(input.selector ?? null);
5295
5460
  return [...report.values()].map(normalizeWebRTCStat);
5296
5461
  };
5297
- var collectMediaWebRTCStatsReport = async (input) => {
5298
- const stats = await collectMediaWebRTCStats(input);
5299
- return buildMediaWebRTCStatsReport({
5300
- ...input,
5301
- stats
5462
+ var buildMediaWebRTCStreamContinuityReport = (input = {}) => {
5463
+ const stats = input.stats ?? [];
5464
+ const previousStats = input.previousStats ?? [];
5465
+ const issues = [];
5466
+ const previousByKey = new Map(previousStats.map((stat) => [statKey(stat), stat]));
5467
+ const audioRtp = stats.filter((stat) => (stat.type === "inbound-rtp" || stat.type === "outbound-rtp") && stringStat(stat, "kind") !== "video" && stringStat(stat, "mediaType") !== "video");
5468
+ const streams = audioRtp.map((stat) => {
5469
+ const direction = stat.type === "outbound-rtp" ? "outbound" : "inbound";
5470
+ const packetsKey = direction === "outbound" ? "packetsSent" : "packetsReceived";
5471
+ const bytesKey = direction === "outbound" ? "bytesSent" : "bytesReceived";
5472
+ const previous = previousByKey.get(statKey(stat));
5473
+ const currentPackets = numericStat(stat, packetsKey);
5474
+ const previousPackets = previous ? numericStat(previous, packetsKey) : undefined;
5475
+ const currentBytes = numericStat(stat, bytesKey);
5476
+ const previousBytes = previous ? numericStat(previous, bytesKey) : undefined;
5477
+ const timeDeltaMs = stat.timestamp !== undefined && previous?.timestamp !== undefined ? stat.timestamp - previous.timestamp : undefined;
5478
+ return {
5479
+ bytesDelta: currentBytes !== undefined && previousBytes !== undefined ? currentBytes - previousBytes : undefined,
5480
+ currentPackets,
5481
+ direction,
5482
+ id: statKey(stat),
5483
+ packetDelta: currentPackets !== undefined && previousPackets !== undefined ? currentPackets - previousPackets : undefined,
5484
+ previousPackets,
5485
+ timeDeltaMs
5486
+ };
5302
5487
  });
5488
+ const inbound = streams.filter((stream) => stream.direction === "inbound");
5489
+ const outbound = streams.filter((stream) => stream.direction === "outbound");
5490
+ const maxObservedGapMs = max(streams.map((stream) => stream.timeDeltaMs).filter((value) => value !== undefined));
5491
+ const stalledInboundStreams = inbound.filter((stream) => input.maxInboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxInboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
5492
+ const stalledOutboundStreams = outbound.filter((stream) => input.maxOutboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxOutboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
5493
+ if (input.requireInboundAudio && inbound.length === 0) {
5494
+ pushIssue(issues, "error", "media.webrtc_inbound_audio_missing", "No inbound WebRTC audio RTP stream was observed.");
5495
+ }
5496
+ if (input.requireOutboundAudio && outbound.length === 0) {
5497
+ pushIssue(issues, "error", "media.webrtc_outbound_audio_missing", "No outbound WebRTC audio RTP stream was observed.");
5498
+ }
5499
+ if (input.maxGapMs !== undefined && maxObservedGapMs !== undefined && maxObservedGapMs > input.maxGapMs) {
5500
+ pushIssue(issues, "warning", "media.webrtc_stream_gap", `Observed WebRTC stream sample gap ${String(maxObservedGapMs)}ms above ${String(input.maxGapMs)}ms.`);
5501
+ }
5502
+ if (stalledInboundStreams > 0) {
5503
+ pushIssue(issues, "error", "media.webrtc_inbound_stalled", `${String(stalledInboundStreams)} inbound WebRTC audio stream(s) stopped receiving packets.`);
5504
+ }
5505
+ if (stalledOutboundStreams > 0) {
5506
+ pushIssue(issues, "error", "media.webrtc_outbound_stalled", `${String(stalledOutboundStreams)} outbound WebRTC audio stream(s) stopped sending packets.`);
5507
+ }
5508
+ return {
5509
+ checkedAt: Date.now(),
5510
+ inboundAudioStreams: inbound.length,
5511
+ issues,
5512
+ maxObservedGapMs,
5513
+ outboundAudioStreams: outbound.length,
5514
+ stalledInboundStreams,
5515
+ stalledOutboundStreams,
5516
+ status: issues.some((issue) => issue.severity === "error") ? "fail" : issues.length > 0 ? "warn" : "pass",
5517
+ streams,
5518
+ totalStats: stats.length
5519
+ };
5303
5520
  };
5304
5521
  var buildMediaPipelineCalibrationReport = (input = {}) => {
5305
5522
  const frames = input.frames ?? [];
@@ -5385,21 +5602,30 @@ var postBrowserMediaReport = async (payload, options) => {
5385
5602
  };
5386
5603
  var createVoiceBrowserMediaReporter = (options) => {
5387
5604
  let interval = null;
5605
+ let previousStats = [];
5388
5606
  const reportOnce = async () => {
5389
5607
  const peerConnection = await resolvePeerConnection(options);
5390
5608
  if (!peerConnection) {
5391
5609
  return;
5392
5610
  }
5393
- const report = await collectMediaWebRTCStatsReport({
5611
+ const stats = await collectMediaWebRTCStats({ peerConnection });
5612
+ const report = buildMediaWebRTCStatsReport({
5394
5613
  ...options,
5395
- peerConnection
5614
+ stats
5615
+ });
5616
+ const continuity = options.continuity === false ? undefined : buildMediaWebRTCStreamContinuityReport({
5617
+ ...options.continuity,
5618
+ previousStats,
5619
+ stats
5396
5620
  });
5397
5621
  const payload = {
5398
5622
  at: Date.now(),
5623
+ continuity,
5399
5624
  report,
5400
5625
  scenarioId: options.getScenarioId?.() ?? null,
5401
5626
  sessionId: options.getSessionId?.() ?? null
5402
5627
  };
5628
+ previousStats = stats;
5403
5629
  options.onReport?.(payload);
5404
5630
  await postBrowserMediaReport(payload, options);
5405
5631
  return payload;
@@ -3382,7 +3382,65 @@ 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;
3387
+ var DEFAULT_TELEPHONY_FORMAT = {
3388
+ channels: 1,
3389
+ container: "raw",
3390
+ encoding: "mulaw",
3391
+ sampleRateHz: 8000
3392
+ };
3393
+ var bytesToBase64 = (audio) => {
3394
+ const bytes = audio instanceof ArrayBuffer ? new Uint8Array(audio) : new Uint8Array(audio.buffer, audio.byteOffset, audio.byteLength);
3395
+ return Buffer.from(bytes).toString("base64");
3396
+ };
3397
+ var base64ToBytes = (value) => new Uint8Array(Buffer.from(value, "base64"));
3398
+ var unknownRecord = (value) => value && typeof value === "object" ? value : {};
3399
+ var firstString = (records, keys) => {
3400
+ for (const record of records) {
3401
+ for (const key of keys) {
3402
+ const value = record[key];
3403
+ if (typeof value === "string" && value.length > 0) {
3404
+ return value;
3405
+ }
3406
+ if (typeof value === "number" && Number.isFinite(value)) {
3407
+ return String(value);
3408
+ }
3409
+ }
3410
+ }
3411
+ return;
3412
+ };
3413
+ var firstNumber = (records, keys) => {
3414
+ for (const record of records) {
3415
+ for (const key of keys) {
3416
+ const value = record[key];
3417
+ if (typeof value === "number" && Number.isFinite(value)) {
3418
+ return value;
3419
+ }
3420
+ if (typeof value === "string") {
3421
+ const parsed = Number(value);
3422
+ if (Number.isFinite(parsed)) {
3423
+ return parsed;
3424
+ }
3425
+ }
3426
+ }
3427
+ }
3428
+ return;
3429
+ };
3430
+ var telephonyDirection = (track) => {
3431
+ const normalized = track?.toLowerCase();
3432
+ if (!normalized) {
3433
+ return "unknown";
3434
+ }
3435
+ if (normalized.includes("inbound") || normalized.includes("caller") || normalized.includes("in")) {
3436
+ return "inbound";
3437
+ }
3438
+ if (normalized.includes("outbound") || normalized.includes("assistant") || normalized.includes("out")) {
3439
+ return "outbound";
3440
+ }
3441
+ return "unknown";
3442
+ };
3443
+ var telephonyFrameKind = (direction) => direction === "outbound" ? "assistant-audio" : "input-audio";
3386
3444
  var normalizeWebRTCStat = (stat) => {
3387
3445
  const sample = {};
3388
3446
  for (const [key, value] of Object.entries(stat)) {
@@ -3392,6 +3450,113 @@ var normalizeWebRTCStat = (stat) => {
3392
3450
  }
3393
3451
  return sample;
3394
3452
  };
3453
+ var parseTelephonyMediaFrame = (input) => {
3454
+ const envelope = input.envelope;
3455
+ const media = unknownRecord(envelope.media);
3456
+ const payload = firstString([media, envelope], ["payload", "audio", "data"]) ?? firstString([unknownRecord(envelope.message)], ["payload"]);
3457
+ if (!payload) {
3458
+ return;
3459
+ }
3460
+ const carrier = input.carrier ?? firstString([envelope], ["provider"]) ?? "telephony";
3461
+ const streamId = firstString([media, envelope], ["streamSid", "stream_id", "streamId", "streamId", "callSid", "call_id"]);
3462
+ const sequenceNumber = firstString([media, envelope], ["sequenceNumber", "sequence_number", "chunk"]);
3463
+ const track = firstString([media, envelope], ["track", "direction"]);
3464
+ const direction = telephonyDirection(track);
3465
+ const timestamp = firstNumber([media, envelope], ["timestamp", "time", "startedAt"]);
3466
+ return {
3467
+ at: timestamp,
3468
+ audio: base64ToBytes(payload),
3469
+ format: input.format ?? DEFAULT_TELEPHONY_FORMAT,
3470
+ id: [
3471
+ carrier,
3472
+ streamId ?? input.sessionId ?? "stream",
3473
+ sequenceNumber ?? timestamp ?? Date.now()
3474
+ ].join(":"),
3475
+ kind: telephonyFrameKind(direction),
3476
+ metadata: {
3477
+ carrier,
3478
+ direction,
3479
+ event: firstString([envelope], ["event", "type"]),
3480
+ sequenceNumber,
3481
+ streamId,
3482
+ track
3483
+ },
3484
+ sessionId: input.sessionId ?? streamId,
3485
+ source: "telephony"
3486
+ };
3487
+ };
3488
+ var serializeTelephonyMediaFrame = (input) => {
3489
+ const carrier = input.carrier ?? input.frame.metadata?.carrier ?? "telephony";
3490
+ const streamId = input.streamId ?? (typeof input.frame.metadata?.streamId === "string" ? input.frame.metadata.streamId : input.frame.sessionId);
3491
+ const sequenceNumber = input.sequenceNumber ?? (typeof input.frame.metadata?.sequenceNumber === "string" || typeof input.frame.metadata?.sequenceNumber === "number" ? input.frame.metadata.sequenceNumber : undefined);
3492
+ const direction = input.frame.kind === "assistant-audio" ? "outbound" : "inbound";
3493
+ const payload = input.frame.audio ? bytesToBase64(input.frame.audio) : "";
3494
+ if (carrier === "twilio") {
3495
+ return {
3496
+ event: "media",
3497
+ sequenceNumber,
3498
+ streamSid: streamId,
3499
+ media: {
3500
+ payload,
3501
+ timestamp: input.frame.at,
3502
+ track: direction
3503
+ }
3504
+ };
3505
+ }
3506
+ if (carrier === "telnyx") {
3507
+ return {
3508
+ event: "media",
3509
+ stream_id: streamId,
3510
+ sequence_number: sequenceNumber,
3511
+ media: {
3512
+ payload,
3513
+ timestamp: input.frame.at,
3514
+ track: direction
3515
+ }
3516
+ };
3517
+ }
3518
+ if (carrier === "plivo") {
3519
+ return {
3520
+ event: "media",
3521
+ streamId,
3522
+ sequenceNumber,
3523
+ media: {
3524
+ payload,
3525
+ timestamp: input.frame.at,
3526
+ track: direction
3527
+ }
3528
+ };
3529
+ }
3530
+ return {
3531
+ event: "media",
3532
+ provider: carrier,
3533
+ sequenceNumber,
3534
+ streamId,
3535
+ media: {
3536
+ payload,
3537
+ timestamp: input.frame.at,
3538
+ track: direction
3539
+ }
3540
+ };
3541
+ };
3542
+ var createTelephonyMediaSerializer = (input) => {
3543
+ const format = input.format ?? DEFAULT_TELEPHONY_FORMAT;
3544
+ return {
3545
+ carrier: input.carrier,
3546
+ format,
3547
+ parse: (envelope) => parseTelephonyMediaFrame({
3548
+ carrier: input.carrier,
3549
+ envelope,
3550
+ format,
3551
+ sessionId: input.sessionId ?? input.streamId
3552
+ }),
3553
+ serialize: (frame) => serializeTelephonyMediaFrame({
3554
+ carrier: input.carrier,
3555
+ frame,
3556
+ streamId: input.streamId
3557
+ })
3558
+ };
3559
+ };
3395
3560
  var buildMediaResamplingPlan = (input) => {
3396
3561
  const required = !formatMatches(input.inputFormat, input.outputFormat);
3397
3562
  return {
@@ -3628,12 +3793,64 @@ var collectMediaWebRTCStats = async (input) => {
3628
3793
  const report = await input.peerConnection.getStats(input.selector ?? null);
3629
3794
  return [...report.values()].map(normalizeWebRTCStat);
3630
3795
  };
3631
- var collectMediaWebRTCStatsReport = async (input) => {
3632
- const stats = await collectMediaWebRTCStats(input);
3633
- return buildMediaWebRTCStatsReport({
3634
- ...input,
3635
- stats
3796
+ var buildMediaWebRTCStreamContinuityReport = (input = {}) => {
3797
+ const stats = input.stats ?? [];
3798
+ const previousStats = input.previousStats ?? [];
3799
+ const issues = [];
3800
+ const previousByKey = new Map(previousStats.map((stat) => [statKey(stat), stat]));
3801
+ const audioRtp = stats.filter((stat) => (stat.type === "inbound-rtp" || stat.type === "outbound-rtp") && stringStat(stat, "kind") !== "video" && stringStat(stat, "mediaType") !== "video");
3802
+ const streams = audioRtp.map((stat) => {
3803
+ const direction = stat.type === "outbound-rtp" ? "outbound" : "inbound";
3804
+ const packetsKey = direction === "outbound" ? "packetsSent" : "packetsReceived";
3805
+ const bytesKey = direction === "outbound" ? "bytesSent" : "bytesReceived";
3806
+ const previous = previousByKey.get(statKey(stat));
3807
+ const currentPackets = numericStat(stat, packetsKey);
3808
+ const previousPackets = previous ? numericStat(previous, packetsKey) : undefined;
3809
+ const currentBytes = numericStat(stat, bytesKey);
3810
+ const previousBytes = previous ? numericStat(previous, bytesKey) : undefined;
3811
+ const timeDeltaMs = stat.timestamp !== undefined && previous?.timestamp !== undefined ? stat.timestamp - previous.timestamp : undefined;
3812
+ return {
3813
+ bytesDelta: currentBytes !== undefined && previousBytes !== undefined ? currentBytes - previousBytes : undefined,
3814
+ currentPackets,
3815
+ direction,
3816
+ id: statKey(stat),
3817
+ packetDelta: currentPackets !== undefined && previousPackets !== undefined ? currentPackets - previousPackets : undefined,
3818
+ previousPackets,
3819
+ timeDeltaMs
3820
+ };
3636
3821
  });
3822
+ const inbound = streams.filter((stream) => stream.direction === "inbound");
3823
+ const outbound = streams.filter((stream) => stream.direction === "outbound");
3824
+ const maxObservedGapMs = max(streams.map((stream) => stream.timeDeltaMs).filter((value) => value !== undefined));
3825
+ const stalledInboundStreams = inbound.filter((stream) => input.maxInboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxInboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
3826
+ const stalledOutboundStreams = outbound.filter((stream) => input.maxOutboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxOutboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
3827
+ if (input.requireInboundAudio && inbound.length === 0) {
3828
+ pushIssue(issues, "error", "media.webrtc_inbound_audio_missing", "No inbound WebRTC audio RTP stream was observed.");
3829
+ }
3830
+ if (input.requireOutboundAudio && outbound.length === 0) {
3831
+ pushIssue(issues, "error", "media.webrtc_outbound_audio_missing", "No outbound WebRTC audio RTP stream was observed.");
3832
+ }
3833
+ if (input.maxGapMs !== undefined && maxObservedGapMs !== undefined && maxObservedGapMs > input.maxGapMs) {
3834
+ pushIssue(issues, "warning", "media.webrtc_stream_gap", `Observed WebRTC stream sample gap ${String(maxObservedGapMs)}ms above ${String(input.maxGapMs)}ms.`);
3835
+ }
3836
+ if (stalledInboundStreams > 0) {
3837
+ pushIssue(issues, "error", "media.webrtc_inbound_stalled", `${String(stalledInboundStreams)} inbound WebRTC audio stream(s) stopped receiving packets.`);
3838
+ }
3839
+ if (stalledOutboundStreams > 0) {
3840
+ pushIssue(issues, "error", "media.webrtc_outbound_stalled", `${String(stalledOutboundStreams)} outbound WebRTC audio stream(s) stopped sending packets.`);
3841
+ }
3842
+ return {
3843
+ checkedAt: Date.now(),
3844
+ inboundAudioStreams: inbound.length,
3845
+ issues,
3846
+ maxObservedGapMs,
3847
+ outboundAudioStreams: outbound.length,
3848
+ stalledInboundStreams,
3849
+ stalledOutboundStreams,
3850
+ status: issues.some((issue) => issue.severity === "error") ? "fail" : issues.length > 0 ? "warn" : "pass",
3851
+ streams,
3852
+ totalStats: stats.length
3853
+ };
3637
3854
  };
3638
3855
  var buildMediaPipelineCalibrationReport = (input = {}) => {
3639
3856
  const frames = input.frames ?? [];
@@ -3719,21 +3936,30 @@ var postBrowserMediaReport = async (payload, options) => {
3719
3936
  };
3720
3937
  var createVoiceBrowserMediaReporter = (options) => {
3721
3938
  let interval = null;
3939
+ let previousStats = [];
3722
3940
  const reportOnce = async () => {
3723
3941
  const peerConnection = await resolvePeerConnection(options);
3724
3942
  if (!peerConnection) {
3725
3943
  return;
3726
3944
  }
3727
- const report = await collectMediaWebRTCStatsReport({
3945
+ const stats = await collectMediaWebRTCStats({ peerConnection });
3946
+ const report = buildMediaWebRTCStatsReport({
3728
3947
  ...options,
3729
- peerConnection
3948
+ stats
3949
+ });
3950
+ const continuity = options.continuity === false ? undefined : buildMediaWebRTCStreamContinuityReport({
3951
+ ...options.continuity,
3952
+ previousStats,
3953
+ stats
3730
3954
  });
3731
3955
  const payload = {
3732
3956
  at: Date.now(),
3957
+ continuity,
3733
3958
  report,
3734
3959
  scenarioId: options.getScenarioId?.() ?? null,
3735
3960
  sessionId: options.getSessionId?.() ?? null
3736
3961
  };
3962
+ previousStats = stats;
3737
3963
  options.onReport?.(payload);
3738
3964
  await postBrowserMediaReport(payload, options);
3739
3965
  return payload;
@@ -0,0 +1,63 @@
1
+ import { Elysia } from 'elysia';
2
+ import type { MediaFrame, MediaTelephonyCarrier, MediaTelephonyEnvelope } from '@absolutejs/media';
3
+ export type VoiceTelephonyMediaStatus = 'fail' | 'pass';
4
+ export type VoiceTelephonyMediaCarrierInput = {
5
+ carrier: MediaTelephonyCarrier;
6
+ envelope?: MediaTelephonyEnvelope;
7
+ };
8
+ export type VoiceTelephonyMediaCarrierReport = {
9
+ audioBytes: number;
10
+ carrier: MediaTelephonyCarrier;
11
+ frame?: MediaFrame;
12
+ issues: string[];
13
+ serialized?: MediaTelephonyEnvelope;
14
+ status: VoiceTelephonyMediaStatus;
15
+ };
16
+ export type VoiceTelephonyMediaReport = {
17
+ carriers: readonly VoiceTelephonyMediaCarrierReport[];
18
+ checkedAt: number;
19
+ issues: string[];
20
+ status: VoiceTelephonyMediaStatus;
21
+ };
22
+ export type VoiceTelephonyMediaRoutesOptions = {
23
+ carriers?: readonly VoiceTelephonyMediaCarrierInput[];
24
+ headers?: HeadersInit;
25
+ htmlPath?: false | string;
26
+ name?: string;
27
+ path?: string;
28
+ title?: string;
29
+ };
30
+ export declare const buildVoiceTelephonyMediaReport: (input?: {
31
+ carriers?: readonly VoiceTelephonyMediaCarrierInput[];
32
+ }) => VoiceTelephonyMediaReport;
33
+ export declare const renderVoiceTelephonyMediaHTML: (report: VoiceTelephonyMediaReport, options?: {
34
+ title?: string;
35
+ }) => string;
36
+ export declare const createVoiceTelephonyMediaRoutes: (options?: VoiceTelephonyMediaRoutesOptions) => Elysia<"", {
37
+ decorator: {};
38
+ store: {};
39
+ derive: {};
40
+ resolve: {};
41
+ }, {
42
+ typebox: {};
43
+ error: {};
44
+ }, {
45
+ schema: {};
46
+ standaloneSchema: {};
47
+ macro: {};
48
+ macroFn: {};
49
+ parser: {};
50
+ response: {};
51
+ }, {}, {
52
+ derive: {};
53
+ resolve: {};
54
+ schema: {};
55
+ standaloneSchema: {};
56
+ response: {};
57
+ }, {
58
+ derive: {};
59
+ resolve: {};
60
+ schema: {};
61
+ standaloneSchema: {};
62
+ response: {};
63
+ }>;