@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.
@@ -2184,7 +2184,65 @@ 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;
2189
+ var DEFAULT_TELEPHONY_FORMAT = {
2190
+ channels: 1,
2191
+ container: "raw",
2192
+ encoding: "mulaw",
2193
+ sampleRateHz: 8000
2194
+ };
2195
+ var bytesToBase64 = (audio) => {
2196
+ const bytes = audio instanceof ArrayBuffer ? new Uint8Array(audio) : new Uint8Array(audio.buffer, audio.byteOffset, audio.byteLength);
2197
+ return Buffer.from(bytes).toString("base64");
2198
+ };
2199
+ var base64ToBytes = (value) => new Uint8Array(Buffer.from(value, "base64"));
2200
+ var unknownRecord = (value) => value && typeof value === "object" ? value : {};
2201
+ var firstString = (records, keys) => {
2202
+ for (const record of records) {
2203
+ for (const key of keys) {
2204
+ const value = record[key];
2205
+ if (typeof value === "string" && value.length > 0) {
2206
+ return value;
2207
+ }
2208
+ if (typeof value === "number" && Number.isFinite(value)) {
2209
+ return String(value);
2210
+ }
2211
+ }
2212
+ }
2213
+ return;
2214
+ };
2215
+ var firstNumber = (records, keys) => {
2216
+ for (const record of records) {
2217
+ for (const key of keys) {
2218
+ const value = record[key];
2219
+ if (typeof value === "number" && Number.isFinite(value)) {
2220
+ return value;
2221
+ }
2222
+ if (typeof value === "string") {
2223
+ const parsed = Number(value);
2224
+ if (Number.isFinite(parsed)) {
2225
+ return parsed;
2226
+ }
2227
+ }
2228
+ }
2229
+ }
2230
+ return;
2231
+ };
2232
+ var telephonyDirection = (track) => {
2233
+ const normalized = track?.toLowerCase();
2234
+ if (!normalized) {
2235
+ return "unknown";
2236
+ }
2237
+ if (normalized.includes("inbound") || normalized.includes("caller") || normalized.includes("in")) {
2238
+ return "inbound";
2239
+ }
2240
+ if (normalized.includes("outbound") || normalized.includes("assistant") || normalized.includes("out")) {
2241
+ return "outbound";
2242
+ }
2243
+ return "unknown";
2244
+ };
2245
+ var telephonyFrameKind = (direction) => direction === "outbound" ? "assistant-audio" : "input-audio";
2188
2246
  var normalizeWebRTCStat = (stat) => {
2189
2247
  const sample = {};
2190
2248
  for (const [key, value] of Object.entries(stat)) {
@@ -2194,6 +2252,113 @@ var normalizeWebRTCStat = (stat) => {
2194
2252
  }
2195
2253
  return sample;
2196
2254
  };
2255
+ var parseTelephonyMediaFrame = (input) => {
2256
+ const envelope = input.envelope;
2257
+ const media = unknownRecord(envelope.media);
2258
+ const payload = firstString([media, envelope], ["payload", "audio", "data"]) ?? firstString([unknownRecord(envelope.message)], ["payload"]);
2259
+ if (!payload) {
2260
+ return;
2261
+ }
2262
+ const carrier = input.carrier ?? firstString([envelope], ["provider"]) ?? "telephony";
2263
+ const streamId = firstString([media, envelope], ["streamSid", "stream_id", "streamId", "streamId", "callSid", "call_id"]);
2264
+ const sequenceNumber = firstString([media, envelope], ["sequenceNumber", "sequence_number", "chunk"]);
2265
+ const track = firstString([media, envelope], ["track", "direction"]);
2266
+ const direction = telephonyDirection(track);
2267
+ const timestamp = firstNumber([media, envelope], ["timestamp", "time", "startedAt"]);
2268
+ return {
2269
+ at: timestamp,
2270
+ audio: base64ToBytes(payload),
2271
+ format: input.format ?? DEFAULT_TELEPHONY_FORMAT,
2272
+ id: [
2273
+ carrier,
2274
+ streamId ?? input.sessionId ?? "stream",
2275
+ sequenceNumber ?? timestamp ?? Date.now()
2276
+ ].join(":"),
2277
+ kind: telephonyFrameKind(direction),
2278
+ metadata: {
2279
+ carrier,
2280
+ direction,
2281
+ event: firstString([envelope], ["event", "type"]),
2282
+ sequenceNumber,
2283
+ streamId,
2284
+ track
2285
+ },
2286
+ sessionId: input.sessionId ?? streamId,
2287
+ source: "telephony"
2288
+ };
2289
+ };
2290
+ var serializeTelephonyMediaFrame = (input) => {
2291
+ const carrier = input.carrier ?? input.frame.metadata?.carrier ?? "telephony";
2292
+ const streamId = input.streamId ?? (typeof input.frame.metadata?.streamId === "string" ? input.frame.metadata.streamId : input.frame.sessionId);
2293
+ const sequenceNumber = input.sequenceNumber ?? (typeof input.frame.metadata?.sequenceNumber === "string" || typeof input.frame.metadata?.sequenceNumber === "number" ? input.frame.metadata.sequenceNumber : undefined);
2294
+ const direction = input.frame.kind === "assistant-audio" ? "outbound" : "inbound";
2295
+ const payload = input.frame.audio ? bytesToBase64(input.frame.audio) : "";
2296
+ if (carrier === "twilio") {
2297
+ return {
2298
+ event: "media",
2299
+ sequenceNumber,
2300
+ streamSid: streamId,
2301
+ media: {
2302
+ payload,
2303
+ timestamp: input.frame.at,
2304
+ track: direction
2305
+ }
2306
+ };
2307
+ }
2308
+ if (carrier === "telnyx") {
2309
+ return {
2310
+ event: "media",
2311
+ stream_id: streamId,
2312
+ sequence_number: sequenceNumber,
2313
+ media: {
2314
+ payload,
2315
+ timestamp: input.frame.at,
2316
+ track: direction
2317
+ }
2318
+ };
2319
+ }
2320
+ if (carrier === "plivo") {
2321
+ return {
2322
+ event: "media",
2323
+ streamId,
2324
+ sequenceNumber,
2325
+ media: {
2326
+ payload,
2327
+ timestamp: input.frame.at,
2328
+ track: direction
2329
+ }
2330
+ };
2331
+ }
2332
+ return {
2333
+ event: "media",
2334
+ provider: carrier,
2335
+ sequenceNumber,
2336
+ streamId,
2337
+ media: {
2338
+ payload,
2339
+ timestamp: input.frame.at,
2340
+ track: direction
2341
+ }
2342
+ };
2343
+ };
2344
+ var createTelephonyMediaSerializer = (input) => {
2345
+ const format = input.format ?? DEFAULT_TELEPHONY_FORMAT;
2346
+ return {
2347
+ carrier: input.carrier,
2348
+ format,
2349
+ parse: (envelope) => parseTelephonyMediaFrame({
2350
+ carrier: input.carrier,
2351
+ envelope,
2352
+ format,
2353
+ sessionId: input.sessionId ?? input.streamId
2354
+ }),
2355
+ serialize: (frame) => serializeTelephonyMediaFrame({
2356
+ carrier: input.carrier,
2357
+ frame,
2358
+ streamId: input.streamId
2359
+ })
2360
+ };
2361
+ };
2197
2362
  var buildMediaResamplingPlan = (input) => {
2198
2363
  const required = !formatMatches(input.inputFormat, input.outputFormat);
2199
2364
  return {
@@ -2430,12 +2595,64 @@ var collectMediaWebRTCStats = async (input) => {
2430
2595
  const report = await input.peerConnection.getStats(input.selector ?? null);
2431
2596
  return [...report.values()].map(normalizeWebRTCStat);
2432
2597
  };
2433
- var collectMediaWebRTCStatsReport = async (input) => {
2434
- const stats = await collectMediaWebRTCStats(input);
2435
- return buildMediaWebRTCStatsReport({
2436
- ...input,
2437
- stats
2598
+ var buildMediaWebRTCStreamContinuityReport = (input = {}) => {
2599
+ const stats = input.stats ?? [];
2600
+ const previousStats = input.previousStats ?? [];
2601
+ const issues = [];
2602
+ const previousByKey = new Map(previousStats.map((stat) => [statKey(stat), stat]));
2603
+ const audioRtp = stats.filter((stat) => (stat.type === "inbound-rtp" || stat.type === "outbound-rtp") && stringStat(stat, "kind") !== "video" && stringStat(stat, "mediaType") !== "video");
2604
+ const streams = audioRtp.map((stat) => {
2605
+ const direction = stat.type === "outbound-rtp" ? "outbound" : "inbound";
2606
+ const packetsKey = direction === "outbound" ? "packetsSent" : "packetsReceived";
2607
+ const bytesKey = direction === "outbound" ? "bytesSent" : "bytesReceived";
2608
+ const previous = previousByKey.get(statKey(stat));
2609
+ const currentPackets = numericStat(stat, packetsKey);
2610
+ const previousPackets = previous ? numericStat(previous, packetsKey) : undefined;
2611
+ const currentBytes = numericStat(stat, bytesKey);
2612
+ const previousBytes = previous ? numericStat(previous, bytesKey) : undefined;
2613
+ const timeDeltaMs = stat.timestamp !== undefined && previous?.timestamp !== undefined ? stat.timestamp - previous.timestamp : undefined;
2614
+ return {
2615
+ bytesDelta: currentBytes !== undefined && previousBytes !== undefined ? currentBytes - previousBytes : undefined,
2616
+ currentPackets,
2617
+ direction,
2618
+ id: statKey(stat),
2619
+ packetDelta: currentPackets !== undefined && previousPackets !== undefined ? currentPackets - previousPackets : undefined,
2620
+ previousPackets,
2621
+ timeDeltaMs
2622
+ };
2438
2623
  });
2624
+ const inbound = streams.filter((stream) => stream.direction === "inbound");
2625
+ const outbound = streams.filter((stream) => stream.direction === "outbound");
2626
+ const maxObservedGapMs = max(streams.map((stream) => stream.timeDeltaMs).filter((value) => value !== undefined));
2627
+ const stalledInboundStreams = inbound.filter((stream) => input.maxInboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxInboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
2628
+ const stalledOutboundStreams = outbound.filter((stream) => input.maxOutboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxOutboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
2629
+ if (input.requireInboundAudio && inbound.length === 0) {
2630
+ pushIssue(issues, "error", "media.webrtc_inbound_audio_missing", "No inbound WebRTC audio RTP stream was observed.");
2631
+ }
2632
+ if (input.requireOutboundAudio && outbound.length === 0) {
2633
+ pushIssue(issues, "error", "media.webrtc_outbound_audio_missing", "No outbound WebRTC audio RTP stream was observed.");
2634
+ }
2635
+ if (input.maxGapMs !== undefined && maxObservedGapMs !== undefined && maxObservedGapMs > input.maxGapMs) {
2636
+ pushIssue(issues, "warning", "media.webrtc_stream_gap", `Observed WebRTC stream sample gap ${String(maxObservedGapMs)}ms above ${String(input.maxGapMs)}ms.`);
2637
+ }
2638
+ if (stalledInboundStreams > 0) {
2639
+ pushIssue(issues, "error", "media.webrtc_inbound_stalled", `${String(stalledInboundStreams)} inbound WebRTC audio stream(s) stopped receiving packets.`);
2640
+ }
2641
+ if (stalledOutboundStreams > 0) {
2642
+ pushIssue(issues, "error", "media.webrtc_outbound_stalled", `${String(stalledOutboundStreams)} outbound WebRTC audio stream(s) stopped sending packets.`);
2643
+ }
2644
+ return {
2645
+ checkedAt: Date.now(),
2646
+ inboundAudioStreams: inbound.length,
2647
+ issues,
2648
+ maxObservedGapMs,
2649
+ outboundAudioStreams: outbound.length,
2650
+ stalledInboundStreams,
2651
+ stalledOutboundStreams,
2652
+ status: issues.some((issue) => issue.severity === "error") ? "fail" : issues.length > 0 ? "warn" : "pass",
2653
+ streams,
2654
+ totalStats: stats.length
2655
+ };
2439
2656
  };
2440
2657
  var buildMediaPipelineCalibrationReport = (input = {}) => {
2441
2658
  const frames = input.frames ?? [];
@@ -2521,21 +2738,30 @@ var postBrowserMediaReport = async (payload, options) => {
2521
2738
  };
2522
2739
  var createVoiceBrowserMediaReporter = (options) => {
2523
2740
  let interval = null;
2741
+ let previousStats = [];
2524
2742
  const reportOnce = async () => {
2525
2743
  const peerConnection = await resolvePeerConnection(options);
2526
2744
  if (!peerConnection) {
2527
2745
  return;
2528
2746
  }
2529
- const report = await collectMediaWebRTCStatsReport({
2747
+ const stats = await collectMediaWebRTCStats({ peerConnection });
2748
+ const report = buildMediaWebRTCStatsReport({
2530
2749
  ...options,
2531
- peerConnection
2750
+ stats
2751
+ });
2752
+ const continuity = options.continuity === false ? undefined : buildMediaWebRTCStreamContinuityReport({
2753
+ ...options.continuity,
2754
+ previousStats,
2755
+ stats
2532
2756
  });
2533
2757
  const payload = {
2534
2758
  at: Date.now(),
2759
+ continuity,
2535
2760
  report,
2536
2761
  scenarioId: options.getScenarioId?.() ?? null,
2537
2762
  sessionId: options.getSessionId?.() ?? null
2538
2763
  };
2764
+ previousStats = stats;
2539
2765
  options.onReport?.(payload);
2540
2766
  await postBrowserMediaReport(payload, options);
2541
2767
  return payload;
@@ -8837,7 +9063,7 @@ var assertVoiceTelephonyWebhookNormalizationEvidence = (input = {}) => {
8837
9063
  return assertion;
8838
9064
  };
8839
9065
  var normalizeToken = (value) => typeof value === "string" ? value.trim().toLowerCase().replace(/\s+/g, "-").replace(/_+/g, "-") : undefined;
8840
- var firstString = (source, keys) => {
9066
+ var firstString2 = (source, keys) => {
8841
9067
  for (const key of keys) {
8842
9068
  const value = source[key];
8843
9069
  if (typeof value === "string" && value.trim()) {
@@ -8848,7 +9074,7 @@ var firstString = (source, keys) => {
8848
9074
  }
8849
9075
  }
8850
9076
  };
8851
- var firstNumber = (source, keys) => {
9077
+ var firstNumber2 = (source, keys) => {
8852
9078
  for (const key of keys) {
8853
9079
  const value = source[key];
8854
9080
  if (typeof value === "number" && Number.isFinite(value)) {
@@ -9209,8 +9435,8 @@ var verifyVoiceTelephonyWebhook = async (input) => {
9209
9435
  var durationMsFromSeconds = (value) => typeof value === "number" ? value * 1000 : undefined;
9210
9436
  var parseVoiceTelephonyWebhookEvent = (input) => {
9211
9437
  const payload = flattenPayload(input.body);
9212
- const provider = firstString(payload, ["provider", "Provider"]) ?? input.provider;
9213
- const status = firstString(payload, [
9438
+ const provider = firstString2(payload, ["provider", "Provider"]) ?? input.provider;
9439
+ const status = firstString2(payload, [
9214
9440
  "CallStatus",
9215
9441
  "call_status",
9216
9442
  "callStatus",
@@ -9220,7 +9446,7 @@ var parseVoiceTelephonyWebhookEvent = (input) => {
9220
9446
  "event_type",
9221
9447
  "type"
9222
9448
  ]);
9223
- const durationMs = firstNumber(payload, ["durationMs", "duration_ms"]) ?? durationMsFromSeconds(firstNumber(payload, [
9449
+ const durationMs = firstNumber2(payload, ["durationMs", "duration_ms"]) ?? durationMsFromSeconds(firstNumber2(payload, [
9224
9450
  "CallDuration",
9225
9451
  "call_duration",
9226
9452
  "callDuration",
@@ -9228,16 +9454,16 @@ var parseVoiceTelephonyWebhookEvent = (input) => {
9228
9454
  "dial_call_duration",
9229
9455
  "duration"
9230
9456
  ]));
9231
- const sipCode = firstNumber(payload, [
9457
+ const sipCode = firstNumber2(payload, [
9232
9458
  "SipResponseCode",
9233
9459
  "sip_response_code",
9234
9460
  "sipCode",
9235
9461
  "sip_code",
9236
9462
  "hangupCauseCode"
9237
9463
  ]);
9238
- const from = firstString(payload, ["From", "from", "caller_id", "callerId"]);
9239
- const to = firstString(payload, ["To", "to", "called_number", "calledNumber"]);
9240
- const target = firstString(payload, [
9464
+ const from = firstString2(payload, ["From", "from", "caller_id", "callerId"]);
9465
+ const to = firstString2(payload, ["To", "to", "called_number", "calledNumber"]);
9466
+ const target = firstString2(payload, [
9241
9467
  "transferTarget",
9242
9468
  "TransferTarget",
9243
9469
  "target",
@@ -9245,7 +9471,7 @@ var parseVoiceTelephonyWebhookEvent = (input) => {
9245
9471
  "department"
9246
9472
  ]);
9247
9473
  return {
9248
- answeredBy: firstString(payload, [
9474
+ answeredBy: firstString2(payload, [
9249
9475
  "AnsweredBy",
9250
9476
  "answered_by",
9251
9477
  "answeredBy",
@@ -9259,7 +9485,7 @@ var parseVoiceTelephonyWebhookEvent = (input) => {
9259
9485
  ...payload
9260
9486
  },
9261
9487
  provider,
9262
- reason: firstString(payload, [
9488
+ reason: firstString2(payload, [
9263
9489
  "Reason",
9264
9490
  "reason",
9265
9491
  "HangupCause",
@@ -9275,7 +9501,7 @@ var parseVoiceTelephonyWebhookEvent = (input) => {
9275
9501
  var defaultSessionId = (input) => {
9276
9502
  const payload = flattenPayload(input.body);
9277
9503
  const metadataSessionId = input.event.metadata?.sessionId;
9278
- return firstString(input.query, ["sessionId", "session_id"]) ?? firstString(payload, [
9504
+ return firstString2(input.query, ["sessionId", "session_id"]) ?? firstString2(payload, [
9279
9505
  "sessionId",
9280
9506
  "session_id",
9281
9507
  "SessionId",
@@ -9290,7 +9516,7 @@ var defaultSessionId = (input) => {
9290
9516
  };
9291
9517
  var defaultIdempotencyKey = (input) => {
9292
9518
  const payload = flattenPayload(input.body);
9293
- const eventId = firstString(payload, [
9519
+ const eventId = firstString2(payload, [
9294
9520
  "id",
9295
9521
  "event_id",
9296
9522
  "eventId",
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,7 +4766,65 @@ 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;
4771
+ var DEFAULT_TELEPHONY_FORMAT = {
4772
+ channels: 1,
4773
+ container: "raw",
4774
+ encoding: "mulaw",
4775
+ sampleRateHz: 8000
4776
+ };
4777
+ var bytesToBase64 = (audio) => {
4778
+ const bytes = audio instanceof ArrayBuffer ? new Uint8Array(audio) : new Uint8Array(audio.buffer, audio.byteOffset, audio.byteLength);
4779
+ return Buffer.from(bytes).toString("base64");
4780
+ };
4781
+ var base64ToBytes = (value) => new Uint8Array(Buffer.from(value, "base64"));
4782
+ var unknownRecord = (value) => value && typeof value === "object" ? value : {};
4783
+ var firstString = (records, keys) => {
4784
+ for (const record of records) {
4785
+ for (const key of keys) {
4786
+ const value = record[key];
4787
+ if (typeof value === "string" && value.length > 0) {
4788
+ return value;
4789
+ }
4790
+ if (typeof value === "number" && Number.isFinite(value)) {
4791
+ return String(value);
4792
+ }
4793
+ }
4794
+ }
4795
+ return;
4796
+ };
4797
+ var firstNumber = (records, keys) => {
4798
+ for (const record of records) {
4799
+ for (const key of keys) {
4800
+ const value = record[key];
4801
+ if (typeof value === "number" && Number.isFinite(value)) {
4802
+ return value;
4803
+ }
4804
+ if (typeof value === "string") {
4805
+ const parsed = Number(value);
4806
+ if (Number.isFinite(parsed)) {
4807
+ return parsed;
4808
+ }
4809
+ }
4810
+ }
4811
+ }
4812
+ return;
4813
+ };
4814
+ var telephonyDirection = (track) => {
4815
+ const normalized = track?.toLowerCase();
4816
+ if (!normalized) {
4817
+ return "unknown";
4818
+ }
4819
+ if (normalized.includes("inbound") || normalized.includes("caller") || normalized.includes("in")) {
4820
+ return "inbound";
4821
+ }
4822
+ if (normalized.includes("outbound") || normalized.includes("assistant") || normalized.includes("out")) {
4823
+ return "outbound";
4824
+ }
4825
+ return "unknown";
4826
+ };
4827
+ var telephonyFrameKind = (direction) => direction === "outbound" ? "assistant-audio" : "input-audio";
4770
4828
  var normalizeWebRTCStat = (stat) => {
4771
4829
  const sample = {};
4772
4830
  for (const [key, value] of Object.entries(stat)) {
@@ -4776,6 +4834,113 @@ var normalizeWebRTCStat = (stat) => {
4776
4834
  }
4777
4835
  return sample;
4778
4836
  };
4837
+ var parseTelephonyMediaFrame = (input) => {
4838
+ const envelope = input.envelope;
4839
+ const media = unknownRecord(envelope.media);
4840
+ const payload = firstString([media, envelope], ["payload", "audio", "data"]) ?? firstString([unknownRecord(envelope.message)], ["payload"]);
4841
+ if (!payload) {
4842
+ return;
4843
+ }
4844
+ const carrier = input.carrier ?? firstString([envelope], ["provider"]) ?? "telephony";
4845
+ const streamId = firstString([media, envelope], ["streamSid", "stream_id", "streamId", "streamId", "callSid", "call_id"]);
4846
+ const sequenceNumber = firstString([media, envelope], ["sequenceNumber", "sequence_number", "chunk"]);
4847
+ const track = firstString([media, envelope], ["track", "direction"]);
4848
+ const direction = telephonyDirection(track);
4849
+ const timestamp = firstNumber([media, envelope], ["timestamp", "time", "startedAt"]);
4850
+ return {
4851
+ at: timestamp,
4852
+ audio: base64ToBytes(payload),
4853
+ format: input.format ?? DEFAULT_TELEPHONY_FORMAT,
4854
+ id: [
4855
+ carrier,
4856
+ streamId ?? input.sessionId ?? "stream",
4857
+ sequenceNumber ?? timestamp ?? Date.now()
4858
+ ].join(":"),
4859
+ kind: telephonyFrameKind(direction),
4860
+ metadata: {
4861
+ carrier,
4862
+ direction,
4863
+ event: firstString([envelope], ["event", "type"]),
4864
+ sequenceNumber,
4865
+ streamId,
4866
+ track
4867
+ },
4868
+ sessionId: input.sessionId ?? streamId,
4869
+ source: "telephony"
4870
+ };
4871
+ };
4872
+ var serializeTelephonyMediaFrame = (input) => {
4873
+ const carrier = input.carrier ?? input.frame.metadata?.carrier ?? "telephony";
4874
+ const streamId = input.streamId ?? (typeof input.frame.metadata?.streamId === "string" ? input.frame.metadata.streamId : input.frame.sessionId);
4875
+ const sequenceNumber = input.sequenceNumber ?? (typeof input.frame.metadata?.sequenceNumber === "string" || typeof input.frame.metadata?.sequenceNumber === "number" ? input.frame.metadata.sequenceNumber : undefined);
4876
+ const direction = input.frame.kind === "assistant-audio" ? "outbound" : "inbound";
4877
+ const payload = input.frame.audio ? bytesToBase64(input.frame.audio) : "";
4878
+ if (carrier === "twilio") {
4879
+ return {
4880
+ event: "media",
4881
+ sequenceNumber,
4882
+ streamSid: streamId,
4883
+ media: {
4884
+ payload,
4885
+ timestamp: input.frame.at,
4886
+ track: direction
4887
+ }
4888
+ };
4889
+ }
4890
+ if (carrier === "telnyx") {
4891
+ return {
4892
+ event: "media",
4893
+ stream_id: streamId,
4894
+ sequence_number: sequenceNumber,
4895
+ media: {
4896
+ payload,
4897
+ timestamp: input.frame.at,
4898
+ track: direction
4899
+ }
4900
+ };
4901
+ }
4902
+ if (carrier === "plivo") {
4903
+ return {
4904
+ event: "media",
4905
+ streamId,
4906
+ sequenceNumber,
4907
+ media: {
4908
+ payload,
4909
+ timestamp: input.frame.at,
4910
+ track: direction
4911
+ }
4912
+ };
4913
+ }
4914
+ return {
4915
+ event: "media",
4916
+ provider: carrier,
4917
+ sequenceNumber,
4918
+ streamId,
4919
+ media: {
4920
+ payload,
4921
+ timestamp: input.frame.at,
4922
+ track: direction
4923
+ }
4924
+ };
4925
+ };
4926
+ var createTelephonyMediaSerializer = (input) => {
4927
+ const format = input.format ?? DEFAULT_TELEPHONY_FORMAT;
4928
+ return {
4929
+ carrier: input.carrier,
4930
+ format,
4931
+ parse: (envelope) => parseTelephonyMediaFrame({
4932
+ carrier: input.carrier,
4933
+ envelope,
4934
+ format,
4935
+ sessionId: input.sessionId ?? input.streamId
4936
+ }),
4937
+ serialize: (frame) => serializeTelephonyMediaFrame({
4938
+ carrier: input.carrier,
4939
+ frame,
4940
+ streamId: input.streamId
4941
+ })
4942
+ };
4943
+ };
4779
4944
  var buildMediaResamplingPlan = (input) => {
4780
4945
  const required = !formatMatches(input.inputFormat, input.outputFormat);
4781
4946
  return {
@@ -5012,12 +5177,64 @@ var collectMediaWebRTCStats = async (input) => {
5012
5177
  const report = await input.peerConnection.getStats(input.selector ?? null);
5013
5178
  return [...report.values()].map(normalizeWebRTCStat);
5014
5179
  };
5015
- var collectMediaWebRTCStatsReport = async (input) => {
5016
- const stats = await collectMediaWebRTCStats(input);
5017
- return buildMediaWebRTCStatsReport({
5018
- ...input,
5019
- stats
5180
+ var buildMediaWebRTCStreamContinuityReport = (input = {}) => {
5181
+ const stats = input.stats ?? [];
5182
+ const previousStats = input.previousStats ?? [];
5183
+ const issues = [];
5184
+ const previousByKey = new Map(previousStats.map((stat) => [statKey(stat), stat]));
5185
+ const audioRtp = stats.filter((stat) => (stat.type === "inbound-rtp" || stat.type === "outbound-rtp") && stringStat(stat, "kind") !== "video" && stringStat(stat, "mediaType") !== "video");
5186
+ const streams = audioRtp.map((stat) => {
5187
+ const direction = stat.type === "outbound-rtp" ? "outbound" : "inbound";
5188
+ const packetsKey = direction === "outbound" ? "packetsSent" : "packetsReceived";
5189
+ const bytesKey = direction === "outbound" ? "bytesSent" : "bytesReceived";
5190
+ const previous = previousByKey.get(statKey(stat));
5191
+ const currentPackets = numericStat(stat, packetsKey);
5192
+ const previousPackets = previous ? numericStat(previous, packetsKey) : undefined;
5193
+ const currentBytes = numericStat(stat, bytesKey);
5194
+ const previousBytes = previous ? numericStat(previous, bytesKey) : undefined;
5195
+ const timeDeltaMs = stat.timestamp !== undefined && previous?.timestamp !== undefined ? stat.timestamp - previous.timestamp : undefined;
5196
+ return {
5197
+ bytesDelta: currentBytes !== undefined && previousBytes !== undefined ? currentBytes - previousBytes : undefined,
5198
+ currentPackets,
5199
+ direction,
5200
+ id: statKey(stat),
5201
+ packetDelta: currentPackets !== undefined && previousPackets !== undefined ? currentPackets - previousPackets : undefined,
5202
+ previousPackets,
5203
+ timeDeltaMs
5204
+ };
5020
5205
  });
5206
+ const inbound = streams.filter((stream) => stream.direction === "inbound");
5207
+ const outbound = streams.filter((stream) => stream.direction === "outbound");
5208
+ const maxObservedGapMs = max(streams.map((stream) => stream.timeDeltaMs).filter((value) => value !== undefined));
5209
+ const stalledInboundStreams = inbound.filter((stream) => input.maxInboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxInboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
5210
+ const stalledOutboundStreams = outbound.filter((stream) => input.maxOutboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxOutboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
5211
+ if (input.requireInboundAudio && inbound.length === 0) {
5212
+ pushIssue(issues, "error", "media.webrtc_inbound_audio_missing", "No inbound WebRTC audio RTP stream was observed.");
5213
+ }
5214
+ if (input.requireOutboundAudio && outbound.length === 0) {
5215
+ pushIssue(issues, "error", "media.webrtc_outbound_audio_missing", "No outbound WebRTC audio RTP stream was observed.");
5216
+ }
5217
+ if (input.maxGapMs !== undefined && maxObservedGapMs !== undefined && maxObservedGapMs > input.maxGapMs) {
5218
+ pushIssue(issues, "warning", "media.webrtc_stream_gap", `Observed WebRTC stream sample gap ${String(maxObservedGapMs)}ms above ${String(input.maxGapMs)}ms.`);
5219
+ }
5220
+ if (stalledInboundStreams > 0) {
5221
+ pushIssue(issues, "error", "media.webrtc_inbound_stalled", `${String(stalledInboundStreams)} inbound WebRTC audio stream(s) stopped receiving packets.`);
5222
+ }
5223
+ if (stalledOutboundStreams > 0) {
5224
+ pushIssue(issues, "error", "media.webrtc_outbound_stalled", `${String(stalledOutboundStreams)} outbound WebRTC audio stream(s) stopped sending packets.`);
5225
+ }
5226
+ return {
5227
+ checkedAt: Date.now(),
5228
+ inboundAudioStreams: inbound.length,
5229
+ issues,
5230
+ maxObservedGapMs,
5231
+ outboundAudioStreams: outbound.length,
5232
+ stalledInboundStreams,
5233
+ stalledOutboundStreams,
5234
+ status: issues.some((issue) => issue.severity === "error") ? "fail" : issues.length > 0 ? "warn" : "pass",
5235
+ streams,
5236
+ totalStats: stats.length
5237
+ };
5021
5238
  };
5022
5239
  var buildMediaPipelineCalibrationReport = (input = {}) => {
5023
5240
  const frames = input.frames ?? [];
@@ -5103,21 +5320,30 @@ var postBrowserMediaReport = async (payload, options) => {
5103
5320
  };
5104
5321
  var createVoiceBrowserMediaReporter = (options) => {
5105
5322
  let interval = null;
5323
+ let previousStats = [];
5106
5324
  const reportOnce = async () => {
5107
5325
  const peerConnection = await resolvePeerConnection(options);
5108
5326
  if (!peerConnection) {
5109
5327
  return;
5110
5328
  }
5111
- const report = await collectMediaWebRTCStatsReport({
5329
+ const stats = await collectMediaWebRTCStats({ peerConnection });
5330
+ const report = buildMediaWebRTCStatsReport({
5112
5331
  ...options,
5113
- peerConnection
5332
+ stats
5333
+ });
5334
+ const continuity = options.continuity === false ? undefined : buildMediaWebRTCStreamContinuityReport({
5335
+ ...options.continuity,
5336
+ previousStats,
5337
+ stats
5114
5338
  });
5115
5339
  const payload = {
5116
5340
  at: Date.now(),
5341
+ continuity,
5117
5342
  report,
5118
5343
  scenarioId: options.getScenarioId?.() ?? null,
5119
5344
  sessionId: options.getSessionId?.() ?? null
5120
5345
  };
5346
+ previousStats = stats;
5121
5347
  options.onReport?.(payload);
5122
5348
  await postBrowserMediaReport(payload, options);
5123
5349
  return payload;