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

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