@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 +7 -0
- package/dist/angular/index.js +233 -7
- package/dist/browserMediaRoutes.d.ts +2 -1
- package/dist/client/htmxBootstrap.js +69 -7
- package/dist/client/index.js +233 -7
- package/dist/index.d.ts +2 -0
- package/dist/index.js +816 -444
- package/dist/react/index.js +233 -7
- package/dist/svelte/index.js +233 -7
- package/dist/telephonyMediaRoutes.d.ts +63 -0
- package/dist/testing/index.js +246 -20
- package/dist/types.d.ts +3 -1
- package/dist/vue/index.js +233 -7
- package/package.json +2 -2
package/dist/testing/index.js
CHANGED
|
@@ -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
|
|
2434
|
-
const stats =
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
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
|
|
2747
|
+
const stats = await collectMediaWebRTCStats({ peerConnection });
|
|
2748
|
+
const report = buildMediaWebRTCStatsReport({
|
|
2530
2749
|
...options,
|
|
2531
|
-
|
|
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
|
|
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
|
|
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 =
|
|
9213
|
-
const status =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
9239
|
-
const to =
|
|
9240
|
-
const target =
|
|
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:
|
|
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:
|
|
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
|
|
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 =
|
|
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
|
|
5016
|
-
const stats =
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
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
|
|
5329
|
+
const stats = await collectMediaWebRTCStats({ peerConnection });
|
|
5330
|
+
const report = buildMediaWebRTCStatsReport({
|
|
5112
5331
|
...options,
|
|
5113
|
-
|
|
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;
|