@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.
@@ -805,7 +805,65 @@ 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;
810
+ var DEFAULT_TELEPHONY_FORMAT = {
811
+ channels: 1,
812
+ container: "raw",
813
+ encoding: "mulaw",
814
+ sampleRateHz: 8000
815
+ };
816
+ var bytesToBase64 = (audio) => {
817
+ const bytes = audio instanceof ArrayBuffer ? new Uint8Array(audio) : new Uint8Array(audio.buffer, audio.byteOffset, audio.byteLength);
818
+ return Buffer.from(bytes).toString("base64");
819
+ };
820
+ var base64ToBytes = (value) => new Uint8Array(Buffer.from(value, "base64"));
821
+ var unknownRecord = (value) => value && typeof value === "object" ? value : {};
822
+ var firstString = (records, keys) => {
823
+ for (const record of records) {
824
+ for (const key of keys) {
825
+ const value = record[key];
826
+ if (typeof value === "string" && value.length > 0) {
827
+ return value;
828
+ }
829
+ if (typeof value === "number" && Number.isFinite(value)) {
830
+ return String(value);
831
+ }
832
+ }
833
+ }
834
+ return;
835
+ };
836
+ var firstNumber = (records, keys) => {
837
+ for (const record of records) {
838
+ for (const key of keys) {
839
+ const value = record[key];
840
+ if (typeof value === "number" && Number.isFinite(value)) {
841
+ return value;
842
+ }
843
+ if (typeof value === "string") {
844
+ const parsed = Number(value);
845
+ if (Number.isFinite(parsed)) {
846
+ return parsed;
847
+ }
848
+ }
849
+ }
850
+ }
851
+ return;
852
+ };
853
+ var telephonyDirection = (track) => {
854
+ const normalized = track?.toLowerCase();
855
+ if (!normalized) {
856
+ return "unknown";
857
+ }
858
+ if (normalized.includes("inbound") || normalized.includes("caller") || normalized.includes("in")) {
859
+ return "inbound";
860
+ }
861
+ if (normalized.includes("outbound") || normalized.includes("assistant") || normalized.includes("out")) {
862
+ return "outbound";
863
+ }
864
+ return "unknown";
865
+ };
866
+ var telephonyFrameKind = (direction) => direction === "outbound" ? "assistant-audio" : "input-audio";
809
867
  var normalizeWebRTCStat = (stat) => {
810
868
  const sample = {};
811
869
  for (const [key, value] of Object.entries(stat)) {
@@ -815,6 +873,113 @@ var normalizeWebRTCStat = (stat) => {
815
873
  }
816
874
  return sample;
817
875
  };
876
+ var parseTelephonyMediaFrame = (input) => {
877
+ const envelope = input.envelope;
878
+ const media = unknownRecord(envelope.media);
879
+ const payload = firstString([media, envelope], ["payload", "audio", "data"]) ?? firstString([unknownRecord(envelope.message)], ["payload"]);
880
+ if (!payload) {
881
+ return;
882
+ }
883
+ const carrier = input.carrier ?? firstString([envelope], ["provider"]) ?? "telephony";
884
+ const streamId = firstString([media, envelope], ["streamSid", "stream_id", "streamId", "streamId", "callSid", "call_id"]);
885
+ const sequenceNumber = firstString([media, envelope], ["sequenceNumber", "sequence_number", "chunk"]);
886
+ const track = firstString([media, envelope], ["track", "direction"]);
887
+ const direction = telephonyDirection(track);
888
+ const timestamp = firstNumber([media, envelope], ["timestamp", "time", "startedAt"]);
889
+ return {
890
+ at: timestamp,
891
+ audio: base64ToBytes(payload),
892
+ format: input.format ?? DEFAULT_TELEPHONY_FORMAT,
893
+ id: [
894
+ carrier,
895
+ streamId ?? input.sessionId ?? "stream",
896
+ sequenceNumber ?? timestamp ?? Date.now()
897
+ ].join(":"),
898
+ kind: telephonyFrameKind(direction),
899
+ metadata: {
900
+ carrier,
901
+ direction,
902
+ event: firstString([envelope], ["event", "type"]),
903
+ sequenceNumber,
904
+ streamId,
905
+ track
906
+ },
907
+ sessionId: input.sessionId ?? streamId,
908
+ source: "telephony"
909
+ };
910
+ };
911
+ var serializeTelephonyMediaFrame = (input) => {
912
+ const carrier = input.carrier ?? input.frame.metadata?.carrier ?? "telephony";
913
+ const streamId = input.streamId ?? (typeof input.frame.metadata?.streamId === "string" ? input.frame.metadata.streamId : input.frame.sessionId);
914
+ const sequenceNumber = input.sequenceNumber ?? (typeof input.frame.metadata?.sequenceNumber === "string" || typeof input.frame.metadata?.sequenceNumber === "number" ? input.frame.metadata.sequenceNumber : undefined);
915
+ const direction = input.frame.kind === "assistant-audio" ? "outbound" : "inbound";
916
+ const payload = input.frame.audio ? bytesToBase64(input.frame.audio) : "";
917
+ if (carrier === "twilio") {
918
+ return {
919
+ event: "media",
920
+ sequenceNumber,
921
+ streamSid: streamId,
922
+ media: {
923
+ payload,
924
+ timestamp: input.frame.at,
925
+ track: direction
926
+ }
927
+ };
928
+ }
929
+ if (carrier === "telnyx") {
930
+ return {
931
+ event: "media",
932
+ stream_id: streamId,
933
+ sequence_number: sequenceNumber,
934
+ media: {
935
+ payload,
936
+ timestamp: input.frame.at,
937
+ track: direction
938
+ }
939
+ };
940
+ }
941
+ if (carrier === "plivo") {
942
+ return {
943
+ event: "media",
944
+ streamId,
945
+ sequenceNumber,
946
+ media: {
947
+ payload,
948
+ timestamp: input.frame.at,
949
+ track: direction
950
+ }
951
+ };
952
+ }
953
+ return {
954
+ event: "media",
955
+ provider: carrier,
956
+ sequenceNumber,
957
+ streamId,
958
+ media: {
959
+ payload,
960
+ timestamp: input.frame.at,
961
+ track: direction
962
+ }
963
+ };
964
+ };
965
+ var createTelephonyMediaSerializer = (input) => {
966
+ const format = input.format ?? DEFAULT_TELEPHONY_FORMAT;
967
+ return {
968
+ carrier: input.carrier,
969
+ format,
970
+ parse: (envelope) => parseTelephonyMediaFrame({
971
+ carrier: input.carrier,
972
+ envelope,
973
+ format,
974
+ sessionId: input.sessionId ?? input.streamId
975
+ }),
976
+ serialize: (frame) => serializeTelephonyMediaFrame({
977
+ carrier: input.carrier,
978
+ frame,
979
+ streamId: input.streamId
980
+ })
981
+ };
982
+ };
818
983
  var buildMediaResamplingPlan = (input) => {
819
984
  const required = !formatMatches(input.inputFormat, input.outputFormat);
820
985
  return {
@@ -1051,12 +1216,64 @@ var collectMediaWebRTCStats = async (input) => {
1051
1216
  const report = await input.peerConnection.getStats(input.selector ?? null);
1052
1217
  return [...report.values()].map(normalizeWebRTCStat);
1053
1218
  };
1054
- var collectMediaWebRTCStatsReport = async (input) => {
1055
- const stats = await collectMediaWebRTCStats(input);
1056
- return buildMediaWebRTCStatsReport({
1057
- ...input,
1058
- stats
1219
+ var buildMediaWebRTCStreamContinuityReport = (input = {}) => {
1220
+ const stats = input.stats ?? [];
1221
+ const previousStats = input.previousStats ?? [];
1222
+ const issues = [];
1223
+ const previousByKey = new Map(previousStats.map((stat) => [statKey(stat), stat]));
1224
+ const audioRtp = stats.filter((stat) => (stat.type === "inbound-rtp" || stat.type === "outbound-rtp") && stringStat(stat, "kind") !== "video" && stringStat(stat, "mediaType") !== "video");
1225
+ const streams = audioRtp.map((stat) => {
1226
+ const direction = stat.type === "outbound-rtp" ? "outbound" : "inbound";
1227
+ const packetsKey = direction === "outbound" ? "packetsSent" : "packetsReceived";
1228
+ const bytesKey = direction === "outbound" ? "bytesSent" : "bytesReceived";
1229
+ const previous = previousByKey.get(statKey(stat));
1230
+ const currentPackets = numericStat(stat, packetsKey);
1231
+ const previousPackets = previous ? numericStat(previous, packetsKey) : undefined;
1232
+ const currentBytes = numericStat(stat, bytesKey);
1233
+ const previousBytes = previous ? numericStat(previous, bytesKey) : undefined;
1234
+ const timeDeltaMs = stat.timestamp !== undefined && previous?.timestamp !== undefined ? stat.timestamp - previous.timestamp : undefined;
1235
+ return {
1236
+ bytesDelta: currentBytes !== undefined && previousBytes !== undefined ? currentBytes - previousBytes : undefined,
1237
+ currentPackets,
1238
+ direction,
1239
+ id: statKey(stat),
1240
+ packetDelta: currentPackets !== undefined && previousPackets !== undefined ? currentPackets - previousPackets : undefined,
1241
+ previousPackets,
1242
+ timeDeltaMs
1243
+ };
1059
1244
  });
1245
+ const inbound = streams.filter((stream) => stream.direction === "inbound");
1246
+ const outbound = streams.filter((stream) => stream.direction === "outbound");
1247
+ const maxObservedGapMs = max(streams.map((stream) => stream.timeDeltaMs).filter((value) => value !== undefined));
1248
+ const stalledInboundStreams = inbound.filter((stream) => input.maxInboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxInboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
1249
+ const stalledOutboundStreams = outbound.filter((stream) => input.maxOutboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxOutboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
1250
+ if (input.requireInboundAudio && inbound.length === 0) {
1251
+ pushIssue(issues, "error", "media.webrtc_inbound_audio_missing", "No inbound WebRTC audio RTP stream was observed.");
1252
+ }
1253
+ if (input.requireOutboundAudio && outbound.length === 0) {
1254
+ pushIssue(issues, "error", "media.webrtc_outbound_audio_missing", "No outbound WebRTC audio RTP stream was observed.");
1255
+ }
1256
+ if (input.maxGapMs !== undefined && maxObservedGapMs !== undefined && maxObservedGapMs > input.maxGapMs) {
1257
+ pushIssue(issues, "warning", "media.webrtc_stream_gap", `Observed WebRTC stream sample gap ${String(maxObservedGapMs)}ms above ${String(input.maxGapMs)}ms.`);
1258
+ }
1259
+ if (stalledInboundStreams > 0) {
1260
+ pushIssue(issues, "error", "media.webrtc_inbound_stalled", `${String(stalledInboundStreams)} inbound WebRTC audio stream(s) stopped receiving packets.`);
1261
+ }
1262
+ if (stalledOutboundStreams > 0) {
1263
+ pushIssue(issues, "error", "media.webrtc_outbound_stalled", `${String(stalledOutboundStreams)} outbound WebRTC audio stream(s) stopped sending packets.`);
1264
+ }
1265
+ return {
1266
+ checkedAt: Date.now(),
1267
+ inboundAudioStreams: inbound.length,
1268
+ issues,
1269
+ maxObservedGapMs,
1270
+ outboundAudioStreams: outbound.length,
1271
+ stalledInboundStreams,
1272
+ stalledOutboundStreams,
1273
+ status: issues.some((issue) => issue.severity === "error") ? "fail" : issues.length > 0 ? "warn" : "pass",
1274
+ streams,
1275
+ totalStats: stats.length
1276
+ };
1060
1277
  };
1061
1278
  var buildMediaPipelineCalibrationReport = (input = {}) => {
1062
1279
  const frames = input.frames ?? [];
@@ -1142,21 +1359,30 @@ var postBrowserMediaReport = async (payload, options) => {
1142
1359
  };
1143
1360
  var createVoiceBrowserMediaReporter = (options) => {
1144
1361
  let interval = null;
1362
+ let previousStats = [];
1145
1363
  const reportOnce = async () => {
1146
1364
  const peerConnection = await resolvePeerConnection(options);
1147
1365
  if (!peerConnection) {
1148
1366
  return;
1149
1367
  }
1150
- const report = await collectMediaWebRTCStatsReport({
1368
+ const stats = await collectMediaWebRTCStats({ peerConnection });
1369
+ const report = buildMediaWebRTCStatsReport({
1151
1370
  ...options,
1152
- peerConnection
1371
+ stats
1372
+ });
1373
+ const continuity = options.continuity === false ? undefined : buildMediaWebRTCStreamContinuityReport({
1374
+ ...options.continuity,
1375
+ previousStats,
1376
+ stats
1153
1377
  });
1154
1378
  const payload = {
1155
1379
  at: Date.now(),
1380
+ continuity,
1156
1381
  report,
1157
1382
  scenarioId: options.getScenarioId?.() ?? null,
1158
1383
  sessionId: options.getSessionId?.() ?? null
1159
1384
  };
1385
+ previousStats = stats;
1160
1386
  options.onReport?.(payload);
1161
1387
  await postBrowserMediaReport(payload, options);
1162
1388
  return payload;
package/dist/index.d.ts CHANGED
@@ -16,8 +16,10 @@ export { assertVoiceRealtimeProviderContractEvidence, buildVoiceRealtimeProvider
16
16
  export type { VoiceRealtimeProviderContractAssertionInput, VoiceRealtimeProviderContractAssertionReport, VoiceRealtimeProviderContractCapability, VoiceRealtimeProviderContractCheck, VoiceRealtimeProviderContractDefinition, VoiceRealtimeProviderContractMatrixPresetOptions, VoiceRealtimeProviderContractMatrixInput, VoiceRealtimeProviderContractMatrixReport, VoiceRealtimeProviderContractRoutesOptions, VoiceRealtimeProviderContractRow, VoiceRealtimeProviderPresetProvider, VoiceRealtimeProviderContractStatus } from './realtimeProviderContracts';
17
17
  export { buildVoiceDiagnosticsMarkdown, createVoiceDiagnosticsRoutes, resolveVoiceDiagnosticsTraceFilter } from './diagnosticsRoutes';
18
18
  export { assertVoiceMediaPipelineEvidence, buildVoiceMediaPipelineReport, createVoiceMediaPipelineRoutes, evaluateVoiceMediaPipelineEvidence, renderVoiceMediaPipelineHTML, renderVoiceMediaPipelineMarkdown } from './mediaPipelineRoutes';
19
+ export { buildVoiceTelephonyMediaReport, createVoiceTelephonyMediaRoutes, renderVoiceTelephonyMediaHTML } from './telephonyMediaRoutes';
19
20
  export { createVoiceBrowserMediaRoutes, getLatestVoiceBrowserMediaReport, renderVoiceBrowserMediaHTML, summarizeVoiceBrowserMedia } from './browserMediaRoutes';
20
21
  export type { VoiceMediaPipelineAssertionInput, VoiceMediaPipelineAssertionReport, VoiceMediaPipelineReport, VoiceMediaPipelineReportOptions, VoiceMediaPipelineRoutesOptions } from './mediaPipelineRoutes';
22
+ export type { VoiceTelephonyMediaCarrierInput, VoiceTelephonyMediaCarrierReport, VoiceTelephonyMediaReport, VoiceTelephonyMediaRoutesOptions, VoiceTelephonyMediaStatus } from './telephonyMediaRoutes';
21
23
  export type { VoiceBrowserMediaReport, VoiceBrowserMediaRoutesOptions, VoiceBrowserMediaSample, VoiceBrowserMediaStatus } from './browserMediaRoutes';
22
24
  export { buildVoiceDemoReadyReport, createVoiceDemoReadyRoutes, renderVoiceDemoReadyHTML } from './demoReadyRoutes';
23
25
  export { buildVoiceDeliverySinkReport, createVoiceDeliverySinkDescriptor, createVoiceDeliverySinkPair, createVoiceDeliverySinkRoutes, createVoiceFileDeliverySink, createVoicePostgresDeliverySink, createVoiceS3DeliverySink, createVoiceSQLiteDeliverySink, createVoiceWebhookDeliverySink, renderVoiceDeliverySinkHTML } from './deliverySinkRoutes';